(02)Cartographer源码无死角解析-(49) 2D点云扫描匹配→相关性暴力匹配1:SearchParameters

news2024/11/21 1:29:21

讲解关于slam一系列文章汇总链接:史上最全slam从零开始,针对于本栏目讲解(02)Cartographer源码无死角解析-链接如下:
(02)Cartographer源码无死角解析- (00)目录_最新无死角讲解:https://blog.csdn.net/weixin_43013761/article/details/127350885
 
文末正下方中心提供了本人 联系方式, 点击本人照片即可显示 W X → 官方认证 {\color{blue}{文末正下方中心}提供了本人 \color{red} 联系方式,\color{blue}点击本人照片即可显示WX→官方认证} 文末正下方中心提供了本人联系方式,点击本人照片即可显示WX官方认证
 

一、前言

在上一篇博客中,大致对扫描匹配暴力求解的原理进行了讲解,同时列举了比较形象的例子。虽然说原理不是很复杂,但是代码实现起来还是存在一定难度的。首先在上一篇博客中提到 LocalTrajectoryBuilder2D::ScanMatch() 函数中,有如下两部分代码是比较重要的:

  // 根据参数决定是否 使用correlative_scan_matching对先验位姿进行校准
  if (options_.use_online_correlative_scan_matching()) {
    const double score = real_time_correlative_scan_matcher_.Match(
        pose_prediction, filtered_gravity_aligned_point_cloud,
        *matching_submap->grid(), &initial_ceres_pose);
    kRealTimeCorrelativeScanMatcherScoreMetric->Observe(score);
  }

  // 使用ceres进行扫描匹配
  ceres_scan_matcher_.Match(pose_prediction.translation(), initial_ceres_pose,
                            filtered_gravity_aligned_point_cloud,
                            *matching_submap->grid(), pose_observation.get(),
                            &summary);

先对第一部分进行讲解,也就是相关性扫描匹配,其主要实现类为 RealTimeCorrelativeScanMatcher2D。为了方便后需的理解,本人做了如下图示,首先在配置文件中可以找到类似如下配置:

  use_online_correlative_scan_matching = false,
  real_time_correlative_scan_matcher = {
    linear_search_window = 0.15, --遍历区域的边长
    angular_search_window = math.rad(1.), --遍历角度,弧度制
    translation_delta_cost_weight = 1e-1, --位姿偏移权重
    rotation_delta_cost_weight = 1e-1, --位姿旋转权重
  },

先看下面的图示,后续依照该图示进行讲解:
在这里插入图片描述

图1
1、白色方格 → 其尺寸为物理单位,这里是一个映射关系,把Cartographe地图映射到栅格地图上。
2、紫色方格 → 需要遍历的区域
3、紫色多边形 → 代表机器人,当然也可以理解为雷达传感器原点,或者点云数据的原点。
4、黄色圆形 → 点云数据。
5、蓝色圆形 → 最远的点云
6、蓝色正方形区域→需要被遍历的区域
7、max_scan_range → 最远点云相对于机器人的距离
8、resolution → 地图分辨率
9、红色线段 → 与max_scan_range一致,长度表示最远点云相对于机器人的距离
10、蓝色角度 → angular_perturbation_step_size,角分辨率

 

二、核心被调函数

上面调用的 real_time_correlative_scan_matcher_.Match 函数为 src/cartographer/cartographer/mapping/internal/2d/scan_matching/real_time_correlative_scan_matcher_2d.cc 文件中的 RealTimeCorrelativeScanMatcher2D::Match() 函数,这里大致看一下逻辑即可,后续会对其进行十分细致的分析。

( 1 ) \color{blue}(1) (1) 对点云进行处理,原本的点云是相对于重力坐标系(Z轴与重力平行,原点与local系相同),这里记 point_cloud= p o i n t s g r a v i t y points^{gravity} pointsgravity,首先需要把点云数据变换到机器人坐标系下,变换之后记记 rotated_point_cloud= p o i n t s t r a c k i n g points^{tracking} pointstracking,基于重力系机器人初始位姿记 initial_pose_estimate= R o b o t t r a c k i n g \mathbf {Robot}^{tracking} Robottracking,那么可得数学公式如下:
p o i n t s t r a c k i n g = R o b o t g r a v i t y t r a c k i n g ∗ p o i n t s g r a v i t y (01) \color{Green} \tag{01}points^{tracking}=\mathbf {Robot}^{tracking}_{gravity}*points^{gravity} pointstracking=Robotgravitytrackingpointsgravity(01)

( 2 ) \color{blue}(2) (2) 首先需要计算出角分辨率,也就是上图中的 angular_perturbation_step_size。源码中使用余弦求角公式 c o s A = b 2 + c 2 − a 2 2 b c (02) \color{Green} \tag{02} cosA=\frac{b^2+c^2-a^2}{2bc} cosA=2bcb2+c2a2(02)令最远的点云距离 max_scan_range = b = c = b = c =b=c,分辨率 resolution=a,这样就可以计算出 cosA,然后再调用 arccos 函数,即可获得角分辨率的度数。

( 3 ) \color{blue}(3) (3) 角度遍历→对点云rotated_point_cloud= p o i n t s t r a c k i n g points^{tracking} pointstracking进行旋转,每次旋转的角度为角分辨 angular_perturbation_step_size,旋转的次数由参数 angular_search_window 进行控制。每次旋转之后的点云都添加到集合 std::vector<sensor::PointCloud> rotated_scans 之中。

( 4 ) \color{blue}(4) (4) 区域遍历→把点云集合 rotated_scans 进行平移,平移到上图中正方形区域的各个方格,然后每个方格的位置都会对 rotated_scans(平移过后) 进行匹配,这里的匹配理解为打分即可。然后获得多个评分不一样的候选解,这里的候选解是以偏差的形式存储的。

( 5 ) \color{blue}(5) (5) 计算所有候选解的加权得分,将计算出的偏差量加上原始位姿获得校正后的位姿。

关于 RealTimeCorrelativeScanMatcher2D::Match() 函数的粗略注释如下,大致看一下即可,对基本流程了解之后,后面就是对细节的分析了。

/**
 * @brief 相关性扫描匹配 - 计算量很大
 * 
 * @param[in] initial_pose_estimate 预测出来的先验位姿
 * @param[in] point_cloud 用于匹配的点云 点云的原点位于local坐标系原点
 * @param[in] grid 用于匹配的栅格地图
 * @param[out] pose_estimate 校正后的位姿
 * @return double 匹配得分
 */
double RealTimeCorrelativeScanMatcher2D::Match(
    const transform::Rigid2d& initial_pose_estimate,
    const sensor::PointCloud& point_cloud, const Grid2D& grid,
    transform::Rigid2d* pose_estimate) const {
  CHECK(pose_estimate != nullptr);

  // Step: 1 将点云旋转到机器人坐标系下
  const Eigen::Rotation2Dd initial_rotation = initial_pose_estimate.rotation();
  const sensor::PointCloud rotated_point_cloud = sensor::TransformPointCloud(
      point_cloud,
      transform::Rigid3f::Rotation(Eigen::AngleAxisf(
          initial_rotation.cast<float>().angle(), Eigen::Vector3f::UnitZ())));

  // 根据配置参数初始化 SearchParameters
  const SearchParameters search_parameters(
      options_.linear_search_window(), options_.angular_search_window(),
      rotated_point_cloud, grid.limits().resolution());

  // Step: 2 生成按照不同角度旋转后的点云集合
  const std::vector<sensor::PointCloud> rotated_scans =
      GenerateRotatedScans(rotated_point_cloud, search_parameters);
  
  // Step: 3 将旋转后的点云集合按照预测出的平移量进行平移, 获取平移后的点在地图中的索引
  const std::vector<DiscreteScan2D> discrete_scans = DiscretizeScans(
      grid.limits(), rotated_scans,
      Eigen::Translation2f(initial_pose_estimate.translation().x(),
                           initial_pose_estimate.translation().y()));
  
  // Step: 4 生成所有的候选解
  std::vector<Candidate2D> candidates =
      GenerateExhaustiveSearchCandidates(search_parameters);
  
  // Step: 5 计算所有候选解的加权得分
  ScoreCandidates(grid, discrete_scans, search_parameters, &candidates);

  // Step: 6 获取最优解
  const Candidate2D& best_candidate =
      *std::max_element(candidates.begin(), candidates.end());
  
  // Step: 7 将计算出的偏差量加上原始位姿获得校正后的位姿
  *pose_estimate = transform::Rigid2d(
      {initial_pose_estimate.translation().x() + best_candidate.x,
       initial_pose_estimate.translation().y() + best_candidate.y},
      initial_rotation * Eigen::Rotation2Dd(best_candidate.orientation));
  return best_candidate.score;
}

 

三、SearchParameters 类

在上面的函数中,其首先是根据公式(1)→ p o i n t s t r a c k i n g = R o b o t g r a v i t y t r a c k i n g ∗ p o i n t s g r a v i t y points^{tracking}=\mathbf {Robot}^{tracking}_{gravity}*points^{gravity} pointstracking=Robotgravitytrackingpointsgravity 把local系下的点云数据point_cloud变换到机器人坐标系下,赋值给变量rotated_point_cloud。虽然可以看到如下代码:

  // 根据配置参数初始化 SearchParameters
  const SearchParameters search_parameters(
      options_.linear_search_window(), options_.angular_search_window(),
      rotated_point_cloud, grid.limits().resolution());

SearchParameters 的主要功能是根据配置文件的参数,计算出暴力匹配过程中需要的参数,以及以缩小搜索范围的函数,先分析一下 correlative_scan_matcher_2d.h 文件

1、参数分析

首先其包含了结构体 LinearBounds,如下:

  struct LinearBounds {
    int min_x;
    int max_x;
    int min_y;
    int max_y;
  };

其单位是像素,表示的是像素偏移,可以理解为一个矩形框,与机器人所在的位置共同确定一个搜索或者说遍历范围。然后还存在如下变量;

  int num_angular_perturbations;            // 每次角度遍历的次数
  double angular_perturbation_step_size;    // 角度分辨率
  double resolution;
  int num_scans;                            // 旋转后的点云集合的个数
  std::vector<LinearBounds> linear_bounds;  // Per rotated scans.

2、构造函数

其存在两个构造函数,第一个是根据配置文件的参数计算出 correlative scan matcher 所需的参数,第二个构造函数则是需要传递已经计算好的参数,如角分辨率,遍历区域偏移值等。第二个构造函数较为简单,所以这里就不进行讲解了,只对第一个构造函数进行分析,位于 src/cartographer/cartographer/mapping/internal/2d/scan_matching/correlative_scan_matcher_2d.cc 文件中

// 构造函数
SearchParameters::SearchParameters(const double linear_search_window,
                                   const double angular_search_window,
                                   const sensor::PointCloud& point_cloud,
                                   const double resolution)
    : resolution(resolution) {

首先其需要传递4个参数,linear_search_window 与 angular_search_window 在配置文件中可以进行设置,其分别用于间接控制搜索区域(遍历区域)的偏移值,与搜索的角分辨率。point_cloud 为点云数据,resolution 表示地图分辨率。首先运行了如下代码:

  // We set this value to something on the order of resolution to make sure that
  // the std::acos() below is defined.
  float max_scan_range = 3.f * resolution;

  // 求得 point_cloud 中雷达数据的 最大的值(最远点的距离)
  for (const sensor::RangefinderPoint& point : point_cloud) {
    const float range = point.position.head<2>().norm();
    max_scan_range = std::max(range, max_scan_range);
  }

该段代码是为了计算出最远点云数据的距离 max_scan_range ,其与 图1中的 max_scan_range 是一致的。且该代码保证了 max_scan_range 最小值必须大于3.f * resolution。这样是为了点云较近的时候搜索角度过大。再看如下代码:

  // 根据论文里的公式 求得角度分辨率 angular_perturbation_step_size
  const double kSafetyMargin = 1. - 1e-3;
  angular_perturbation_step_size =
      kSafetyMargin * std::acos(1. - common::Pow2(resolution) /
                                         (2. * common::Pow2(max_scan_range)));

该部分代码就与公式(2) c o s A = b 2 + c 2 − a 2 2 b c cosA=\frac{b^2+c^2-a^2}{2bc} cosA=2bcb2+c2a2 对应起来了,其 max_scan_range=b=c,resolution=a。再调用 std::acos 函数求解角分辨率。其上的 kSafetyMargin 是为了限制角度超过边界。那么再接着往下分析:

  // angular_search_window 除以分辨率得到遍历该角度需要搜索的次数。
  num_angular_perturbations =
      std::ceil(angular_search_window / angular_perturbation_step_size);
  // num_scans是要生成旋转点云的个数, 将 num_angular_perturbations 扩大了2倍
  num_scans = 2 * num_angular_perturbations + 1;

这里需要注意一个点,配置文件中的参数 angular_perturbation_step_size 表示单个角度方向搜索的角度,所以乘以2才表示左右两个方向的角度。这里的+1,可以理解为中间位置(平行y轴)时的搜索。也就是说最终 num_scans 表示搜索完 angular_search_window 角度的范围,需要旋转变换 num_scans 次,每次旋转的角度为角分辨率 angular_perturbation_step_size。求得角度搜索范围之后,就是求得x,y(图1的矩形)区域偏移值了,如下所示:

  // XY方向的搜索范围, 单位是多少个栅格
  const int num_linear_perturbations =
      std::ceil(linear_search_window / resolution);
      
  linear_bounds.reserve(num_scans);
  // 每一次角度的旋转,需要对所有区域进行遍历
  for (int i = 0; i != num_scans; ++i) {  
    linear_bounds.push_back(
        LinearBounds{-num_linear_perturbations, num_linear_perturbations,
                     -num_linear_perturbations, num_linear_perturbations});
  }

这里是把每个搜索角度需要遍历矩形框偏移值先计算出来。根据前面的分析,先毛估一下(回忆下面的类容):

1_(-35)   1_(-30)   1_(-25) ......  1_(30)   1_(35)
2_(-35)   2_(-30)   2_(-25) ......  2_(30)   2_(35)
......
6_(-35)   6_(-30)   6_(-25) ......  6_(30)   2_(35)

猜测其是按行进行遍历的,假设其在角度为 -35 的时候,遍历完所有的方格,然后向右旋转5度,再遍历完所有的方格,依此循环。当然,这里仅仅是猜测,后续分析再做最终确认。这样就把 SearchParameters 函数的构造函数分析完成了。

3、ShrinkToFit()

该函数比较奇怪,在构造函数中的变量 linear_bounds 只是包含了一小块需要遍历的区域,而该函数对 linear_bounds 进行了扩大,把所有的点云都包含在这个范围之内了,代码注释如下:

// 计算每一帧点云 在保证最后一个点能在地图范围内时 的最大移动范围
void SearchParameters::ShrinkToFit(const std::vector<DiscreteScan2D>& scans,
                                   const CellLimits& cell_limits) {
  CHECK_EQ(scans.size(), num_scans);
  CHECK_EQ(linear_bounds.size(), num_scans);

  // 遍历生成的旋转后的很多scan
  for (int i = 0; i != num_scans; ++i) {
    Eigen::Array2i min_bound = Eigen::Array2i::Zero();
    Eigen::Array2i max_bound = Eigen::Array2i::Zero();

    // 对点云的每一个点进行遍历, 确定这帧点云的最大最小的坐标索引
    for (const Eigen::Array2i& xy_index : scans[i]) {
      // Array2i.min的作用是 获取对应元素的最小值组成新的Array2i
      min_bound = min_bound.min(-xy_index);
      max_bound = max_bound.max(Eigen::Array2i(cell_limits.num_x_cells - 1,
                                               cell_limits.num_y_cells - 1) -
                                xy_index);
    }

    //调整linear_bounds,使点云被包含在其区全域内
    linear_bounds[i].min_x = std::max(linear_bounds[i].min_x, min_bound.x());
    linear_bounds[i].max_x = std::min(linear_bounds[i].max_x, max_bound.x());
    linear_bounds[i].min_y = std::max(linear_bounds[i].min_y, min_bound.y());
    linear_bounds[i].max_y = std::min(linear_bounds[i].max_y, max_bound.y());
  }
}

 

四、结语

对于 SearchParameters::ShrinkToFit() 函数,本人存在一些疑问,不过在 src/cartographer/cartographer/mapping/internal/2d/scan_matching/real_time_correlative_scan_matcher_2d.cc 文件中,似乎并没有调用该函数,所以本人也没有深究了,有兴趣的朋友可以深入了解一下。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/159146.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

LeetCode分类刷题----链表篇

链表链表1.移除链表元素203.移除链表元素707.设计链表2.反转链表206.反转链表3.两两交换链表中的节点24.两两交换链表中的节点4.删除链表中的倒数第N个节点19.删除链表的倒数第N个节点5.链表相交07.链表相交6.环形链表141.环形链表142.环形链表II链表 1.移除链表元素 203.移除…

成功解决VMware安装操作系统出现分辨率的问题

文章目录问题重现问题原因问题解决方法一&#xff1a;拓展&#xff1a;1. 电脑分辨率&#xff1a;2. xrandr命令3. 查询后如果没有合适的分辨率解决方案参考资料问题重现 如下图&#xff1a; 在VMware16上安装ubuntu操作系统的时候&#xff0c;出现分辨率问题&#xff0c; 导致…

如何录屏有声音?如何录制带声音的视频

平常我们会通过录屏的方式录制电脑画面&#xff0c;然后再保存下来。那您是不是遇到过这种情况&#xff1a;录制的录屏文件只有画面没有声音。没有声音的视频还能修复吗&#xff1f;如何录屏有声音&#xff1f;怎样才能录制带声音的视频&#xff1f;今天小编教大家如何在录屏的…

前端基础(十三)_定时器(间歇定时器、延迟定时器)

定时器 定时器共两种&#xff0c;setInterval及setTimeout&#xff1a; 1、setInterval&#xff1a;重复执行或者叫间歇执行&#xff0c;即隔某个时间就执行一次 2、setTimeout&#xff1a;延迟执行&#xff0c;延迟某个特定的时间开始执行&#xff0c;只执行一次 语法&#x…

代码随想录算法训练营第10天 232.用栈实现队列、225. 用队列实现栈

代码随想录算法训练营第10天 232.用栈实现队列、225. 用队列实现栈 用栈实现队列 力扣题目链接(opens new window) 使用栈实现队列的下列操作&#xff1a; push(x) – 将一个元素放入队列的尾部。 pop() – 从队列首部移除元素。 peek() – 返回队列首部的元素。 empty() –…

十分好用的跨浏览器测试工具,建议收藏!!!

跨浏览器测试是确保web应用程序的功能在不同浏览器、浏览器版本和操作系统直接保持功能和质量一致的过程&#xff0c;可以为用户提供更好的用户体验&#xff0c;帮助企业通过更易访问的网站获得满意客户&#xff0c;可以使web应用程序在不同平台上兼容。在跨浏览器测试过程中&a…

Vulnhub靶机:DIGITALWORLD.LOCAL_ DEVELOPMENT

目录介绍信息收集主机发现主机信息探测网站探测SSH登录lshell绕过sudo提权介绍 系列&#xff1a;digitalworld.local&#xff08;此系列共9台&#xff09; 发布日期&#xff1a;2018 年 12 月 28 日 难度&#xff1a;中级 运行环境&#xff1a;Virtualbox运行失败&#xff0c;…

写作的“收益”超乎想象

十余年写作经验倾囊相授&#xff0c;全面提升你的技术写作能力&#xff01; 前言 技术从业人员普遍比较务实&#xff0c;也就是用心做好分配给自己的任务&#xff0c;努力担负起自己应尽的责任&#xff0c;因为大家都相信&#xff0c;付出必有回报&#xff0c;金字总会闪光。 …

【干货】普通单双面板的生产工艺流程(二)

衔接上文&#xff0c;继续为朋友们分享普通单双面板的生产工艺流程。 如图&#xff0c;第二道主流程为钻孔。 钻孔的目的为&#xff1a; 对PCB进行钻孔&#xff0c;便于后续识别、定位、插件及导通。 目前&#xff0c;行业内主流的PCB钻孔方式为&#xff1a;机械钻孔、激光钻…

引蜘蛛软件哪款有效果?多少钱怎么购买?

引蜘蛛软件哪款有效果?多少钱怎么购买?怎教你查看一个IP地址是不是搜索引擎官方蜘蛛的参考方法#IP地址#官方蜘蛛#搜索引擎官 大家好&#xff0c;今天给大家分享的是关于怎么查看一个 ip 地址是不是搜索引擎官方蜘蛛的参考方法。 很多做网站的小伙伴们肯定会用到这个方式。 有…

用 Python 制作空间数据可视化

大数据时代到来&#xff0c;随着智能设备与物联网技术的普及&#xff0c;人在社会生产活动中会产生大量的数据。在我们的日常活动中&#xff0c;手机会记录下我们到访过的地点&#xff1b;在使用城市公交IC卡、共享单车等服务时&#xff0c;服务供应商会知道这些出行需求产生的…

gdb相关知识

cdir和cwd 当我们用gdb的命令show dir的时候&#xff0c;显示源码搜寻目录&#xff1a; cdir: 代表编译路径&#xff0c;可以打个断点&#xff0c;然后用info source命令查看。 cwd: 代表当下调试的目录&#xff0c;直接用pwd就可以。 添加新的搜索路径 dir /opt/nmt搜索路…

Gemini撕DCG诉感情被骗,灰度百亿大饼持仓却不会爆雷?

插播&#xff1a;《刘教链比特币原理》音频课正在连载中。今天次条是第一章第2节“1-2 比特币的特点和使用”&#xff0c;推荐每一位读者学习。点击此处查看付费合集详情[链接]以及上一课“1-1 五分钟告诉你什么是比特币和区块链”[链接]。* * *比特币今晨突然急速上涨&#xf…

Qt OpenGL(09)在着色器中实现旋转的彩色正方体

文章目录在着色器中实现旋转的彩色正方体旋转矩阵沿x轴旋转&#xff1a;沿y轴旋转&#xff1a;沿z轴旋转&#xff1a;在顶点着色器中实现顶一个vec3的变量 theta计算余弦和正弦值定义3个旋转矩阵最终代码在着色器中实现旋转的彩色正方体 一直觉得用OpenGL 画一个立方体或者彩色…

黑马学ElasticSearch(八)

目录&#xff1a; &#xff08;1&#xff09;黑马旅游案例-搜素-分页 &#xff08;2&#xff09;黑马旅游案例-条件过滤 &#xff08;3&#xff09;黑马旅游案例-我附近的酒店 &#xff08;4&#xff09;黑马旅游案例-广告置顶 &#xff08;1&#xff09;黑马旅游案例-搜素…

C语言 自定义类型 之 【结构体】

文章目录前言结构体类型的声明结构的自引用结构体变量的定义和初始化定义初始化结构体内存对齐结构体传参结构体实现位段什么是位段&#xff1f;位段的内存分配位段的跨平台问题位段的应用写在最后前言 C语言中结构体是一种用户自定义的数据类型&#xff0c;它相当于一个小型的…

Python3 微信支付(小程序支付)V3接口

起因&#xff1a; 因公司项目需要网上充值功能&#xff0c;从而对接微信支付&#xff0c;目前也只对接了微信支付的小程序支付功能&#xff0c;在网上找到的都是对接微信支付V2版本接口,与我所对接的接口版本不一致&#xff0c;无法使用&#xff0c;特此记录下微信支付完成功能…

中缀表达式怎么转后缀表达式

对于中缀表达式&#xff1a;1 2 * 3 中缀表达式是相对于人来说的&#xff0c;因为我们人是会判断和*的运算优先级谁高谁低 但是计算机是不会判断的&#xff0c;因为计算机是默认从左向右读取数据&#xff0c;它先遇到 就会计算&#xff0c;其结果是不对的。它不会提前看到后面…

使用nvm实现多个Node.js版本之间切换

使用nvm实现多个Node.js版本之间切换1.先卸载掉本系统中原有的node版本。2.去github上下载nvm安装包3.安装node常用的一些nvm命令什么是nvm&#xff1f; nvm是一个简单的bash脚本&#xff0c;它是用来管理系统中多个已存的Node.js版本。这样做主要是我的vue项目对node的版本有…

6.3、动态主机配置协议 DHCP

1、DHCP的作用 如下所示&#xff0c;我们如何配置用户主机&#xff0c;才能是用户主机正常访问网络中的 Web 服务器 即&#xff1a;需要给网络中的各主机正确配置 IP 地址、子网掩码、默认网关、DNS 服务器等网络相关配置信息 例如&#xff1a;如下所示&#xff0c;手工配置的…