(02)Cartographer源码无死角解析-(74) 2D后端优化→OptimizationProblem2D-里程计、local位姿、GPU残差

讲解关于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官方认证
 

一、前言

上一篇博客介绍的 landmark 数据的残差与优化,最后本人分享了如下:

核心理解 : \color{red}核心理解: 核心理解: 在上一篇博客中介绍的约束残差(节点相对与子图位姿的残差),其主是优化每个节点以及每个子图的位姿。可想而知,其是离散的。但是轨迹是一条连续平滑的曲线,所以本质上我们希望通过插值获取到的位姿也是准确的。恰好, landmark帧的时间点,也激光雷达数据帧的时间点是不一样的,所以如果 landmark帧的时间点位于两个激光雷达数据帧的时间点,那么可以把landmark帧相对于机器人的位姿作为一个约束,对两节点插值之后的位姿进行优化,从而广播到两个节点,间接对两个节点进行优化。

现在回到 OptimizationProblem2D::Solve() 函数,到目前为止,已经把:

  // Add cost functions for landmarks.
  // Step: landmark数据 与 通过2个节点位姿插值出来的相对位姿 的差值作为残差项
  AddLandmarkCostFunctions(landmark_nodes, node_data_, &C_nodes, &C_landmarks,
                           &problem, options_.huber_scale());

讲解完成了,不过看起来,任务量还是很多的,似乎还剩下很多代码都没有讲解。起始不然,因为后面的代码比较简单,另外再前面已经接触过类似的代码,分析起来就没有那么困难。

二、残差准备

接着代码 OptimizationProblem2D::Solve() 函数调用的 AddLandmarkCostFunctions() 之后继续分析。首先其又运行了一个for循环,且其中也嵌套一个层for循环,先来看第一层循环,代码如下:

  // 遍历多个轨迹, 添加里程计与local结果的残差
  for (auto node_it = node_data_.begin(); node_it != node_data_.end();) {
    // 获取每个节点的轨迹id
    const int trajectory_id = node_it->id.trajectory_id;
    // 获取这条轨迹的最后一个位置的迭代器
    const auto trajectory_end = node_data_.EndOfTrajectory(trajectory_id);
    // 如果轨迹是frozen的, 则无需处理直接跳过
    if (frozen_trajectories.count(trajectory_id) != 0) {
      node_it = trajectory_end;
      continue;
    }

    auto prev_node_it = node_it;

需要注意,这里的for循环时对轨迹遍历,并不是对节点的遍历,如果 node_data_ 中包含了多条轨迹的节点,node_data_.begin() 表示 trajectory_id==0 的第一个节点,而 node_data_.end() 表示 trajectory_id 等于最大轨迹的第一个节点。先获得轨迹起始节点 node_it 对应轨迹标识 trajectory_id。然后获得这条轨迹结束位姿的迭代器,记录在 trajectory_end,接着判断一下目前节点对应的轨迹是否为冻结状态,如果为冻结状态则 continue 跳出第一层循环。然后把当前 node_it 记录给 prev_node_it,也就是 prev_node_it 记录的目前轨迹的起始节点。接着代码分析,可以看到其嵌套了一个循环:

    // 遍历一个轨迹的所有节点, 添加里程计与local结果的残差
    for (++node_it; node_it != trajectory_end; ++node_it) {
      const NodeId first_node_id = prev_node_it->id;
      const NodeSpec2D& first_node_data = prev_node_it->data;
      prev_node_it = node_it;

      const NodeId second_node_id = node_it->id;
      const NodeSpec2D& second_node_data = node_it->data;

      // 如果节点的索引不连续, 跳过
      if (second_node_id.node_index != first_node_id.node_index + 1) {
        continue;
      }

该循环就是对一条轨迹的节点进行遍历,prev_node_it 主要用于记录上一节点,然后判断当前节点与上一节点索引是否连续,如果不连续,则会 continue 跳出循环,该节点不参与下面的残差计算。

另外,其还获取了当前节点与上一节点的节点数据,分别存储于 first_node_data 与 second_node_data。

三、里程计插值

首先根据两个连续的节点数据 first_node_data 与 second_node_data 的时间,然后对里程计数据进行插值处理,获得里程计在这两个时间点的 local 系下的位姿,里程计数据的线性插值,被调代码如下:

      // Add a relative pose constraint based on the odometry (if available).
      // 根据里程计数据进行插值得到的2个节点间的坐标变换
      std::unique_ptr<transform::Rigid3d> relative_odometry =
          CalculateOdometryBetweenNodes(trajectory_id, first_node_data,
                                        second_node_data);

函数 CalculateOdometryBetweenNodes() 实现于 optimization_problem_2d.cc 文件中,代码注释如下:

/**
 * @brief 根据里程计数据算出两个节点间的相对坐标变换
 * 
 * @param[in] trajectory_id 轨迹的id
 * @param[in] first_node_data 前一个节点数据
 * @param[in] second_node_data 后一个节点数据
 * @return std::unique_ptr<transform::Rigid3d> 两个节点的坐标变换
 */
std::unique_ptr<transform::Rigid3d>
OptimizationProblem2D::CalculateOdometryBetweenNodes(
    const int trajectory_id, const NodeSpec2D& first_node_data,
    const NodeSpec2D& second_node_data) const {

  if (odometry_data_.HasTrajectory(trajectory_id)) {
    // 插值得到time时刻的里程计数据
    const std::unique_ptr<transform::Rigid3d> first_node_odometry =
        InterpolateOdometry(trajectory_id, first_node_data.time);
    const std::unique_ptr<transform::Rigid3d> second_node_odometry =
        InterpolateOdometry(trajectory_id, second_node_data.time);

    if (first_node_odometry != nullptr && second_node_odometry != nullptr) {
      // 根据里程计数据算出的相对坐标变换
      // 需要注意的是, 实际上在optimization_problem中, node的位姿都是2d平面上的
      // 而odometry的pose是带姿态的, 因此要将轮速计插值出来的位姿转到平面上
      transform::Rigid3d relative_odometry =
          transform::Rigid3d::Rotation(first_node_data.gravity_alignment) *
          first_node_odometry->inverse() * (*second_node_odometry) *
          transform::Rigid3d::Rotation(
              second_node_data.gravity_alignment.inverse());

      return absl::make_unique<transform::Rigid3d>(relative_odometry);
    }
  }
  return nullptr;
}

其代码也比较简单,就是先判断一下 odometry_data_ 中有没有 trajectory_id 对应的里程计数据,如果有就根据 first_node_data 与 second_node_data 获得需要插值求得位姿的时刻,然后进行插值。这里的插值函数 InterpolateOdometry() 其权重依旧由 time 与前后两个里程计节点的时间距离来决定的。求得 first_node_data.time 与 second_node_data.time 对应插值里程计位姿之后的 local 位姿之后,再进一步获得两节点基于里程计的相对位姿变换。

四、里程计残差

      // Step: 第三种残差 节点与节点间在global坐标系下的相对坐标变换 与 通过里程计数据插值出的相对坐标变换 的差值作为残差项
      // 如果存在里程计则可增加一个残差
      if (relative_odometry != nullptr) {
        problem.AddResidualBlock(
            CreateAutoDiffSpaCostFunction(Constraint::Pose{
                *relative_odometry, options_.odometry_translation_weight(),
                options_.odometry_rotation_weight()}),
            nullptr /* loss function */, 
            C_nodes.at(first_node_id).data(),
            C_nodes.at(second_node_id).data());
      }

如果存在里程计则可增加一个残差,该残差以 relative_odometry 作为帧值(根据里程计计算出来两节点的位姿变换),然后让根据两节点global系下的节点位姿计算出两节点的相对位姿,优化过程就是让后者向前者靠近。

四、local残差

这是一种比较好理解的残差,再前端求得节点位姿时,使用了扫描匹配,以及Ceres优化,尽量让一帧点云全部打在障碍物上,很明显其时比较优的解,这里我们认为其时正确的,对于相邻的两个节点,都是通过这种方式估算出来的 local 位姿,因为间隔时间短,他们之间的累计误差时可以忽略的,也就是说, local 系下两个节点的相对变换基本同理基本时正确的,可以把其作为一个约束来对待,让 global 系下两个节点之间的位姿变换通过Ceres优化,逼近于local系下对应两个节点的位姿变换。代码如下所示:

      // Add a relative pose constraint based on consecutive local SLAM poses.
      // 计算相邻2个节点在local坐标系下的坐标变换
      const transform::Rigid3d relative_local_slam_pose =
          transform::Embed3D(first_node_data.local_pose_2d.inverse() *
                             second_node_data.local_pose_2d);
      // Step: 第四种残差 节点与节点间在global坐标系下的相对坐标变换 与 相邻2个节点在local坐标系下的相对坐标变换 的差值作为残差项
      problem.AddResidualBlock(
          CreateAutoDiffSpaCostFunction(
              Constraint::Pose{relative_local_slam_pose,
                               options_.local_slam_pose_translation_weight(),
                               options_.local_slam_pose_rotation_weight()}),
          nullptr /* loss function */, 
          C_nodes.at(first_node_id).data(),
          C_nodes.at(second_node_id).data());
    }
  }

五、GPS残差-准备工作与约束计算

对于GPS残差部分,又是两个for循环进行嵌套的代码,且与前面十分相似如下,第一层for循环依旧是对轨迹的遍历。

 // 遍历多个轨迹, 添加gps的残差
  std::map<int, std::array<double, 3>> C_fixed_frames;
  for (auto node_it = node_data_.begin(); node_it != node_data_.end();) {
    const int trajectory_id = node_it->id.trajectory_id;
    const auto trajectory_end = node_data_.EndOfTrajectory(trajectory_id);
    if (!fixed_frame_pose_data_.HasTrajectory(trajectory_id)) {
      node_it = trajectory_end;
      continue;
    }

    const TrajectoryData& trajectory_data = trajectory_data_.at(trajectory_id);
    bool fixed_frame_pose_initialized = false;

其先判断一下该轨迹是否存在GPU数据,没有没有,则 continue 遍历下一条轨迹。如果存在则获得该轨迹信息,轨迹信息主要包含了如下内容:

  struct TrajectoryData {
    double gravity_constant = 9.8;
    std::array<double, 4> imu_calibration{{1., 0., 0., 0.}};
    absl::optional<transform::Rigid3d> fixed_frame_origin_in_map;
  };

先把 fixed_frame_pose_initialized 设置为 false,接着就是对轨迹的每个节点进行遍历了:

    // 遍历一个轨迹的所有节点, 添加gps的残差
    for (; node_it != trajectory_end; ++node_it) {
      const NodeId node_id = node_it->id;
      const NodeSpec2D& node_data = node_it->data;

      // 根据节点的时间对gps数据进行插值, 获取这个时刻的gps数据的位姿
      const std::unique_ptr<transform::Rigid3d> fixed_frame_pose =
          Interpolate(fixed_frame_pose_data_, trajectory_id, node_data.time);
      // 要找到第一个有效的数据才能进行残差的计算
      if (fixed_frame_pose == nullptr) {
        continue;
      }
      // gps数据到gps第一个数据间的坐标变换
      const Constraint::Pose constraint_pose{
          *fixed_frame_pose, options_.fixed_frame_pose_translation_weight(),
          options_.fixed_frame_pose_rotation_weight()};      

先获得遍历轨迹节的time,利用 FixedFramePoseData 数据进行插值,获得 GPS该时刻的位姿 fixed_frame_pose,需要注意 gps数据是到gps第一个数据间的坐标变换,那么可以为这个变换创建一个约束constraint_pose,其中 constraint_pose.zbar_ij 就是 *fixed_frame_pose。

五、GPS残差-global系到gps系变换

在建立好 约束 constraint_pose 之后,如果 fixed_frame_pose_initialized 不为true,也就说目前遍历的是该轨迹的第一个节点,那么则对GPS位姿进行一个初始化处理:

      // 计算gps坐标系原点在global坐标系下的坐标
      if (!fixed_frame_pose_initialized) {
        transform::Rigid2d fixed_frame_pose_in_map;
        // 如果设置了gps数据的原点
        if (trajectory_data.fixed_frame_origin_in_map.has_value()) {
          fixed_frame_pose_in_map = transform::Project2D(
              trajectory_data.fixed_frame_origin_in_map.value());
        } 
        // 第一次优化之前执行的是这里
        else {
          // 计算gps第一个数据在global坐标系下的坐标, 相当于gps数据的坐标系原点在global坐标系下的坐标
          fixed_frame_pose_in_map =
              node_data.global_pose_2d *
              transform::Project2D(constraint_pose.zbar_ij).inverse();
        }

        // 保存gps坐标系原点在global坐标系下的坐标
        C_fixed_frames.emplace(trajectory_id,
                               FromPose(fixed_frame_pose_in_map));
        fixed_frame_pose_initialized = true;
      }

其先判断一下该轨迹是否设置了GPS原点,也就是 trajectory_data.fixed_frame_origin_in_map 参数是否设置,如果设置了,则把其映射到2D平面赋值给 fixed_frame_pose_in_map。

如果没有设置,node_data.global_pose_2d 表示global下机器人位姿,ransform::Project2D(constraint_pose.zbar_ij) 表示机器人在GPS下的位姿,

          fixed_frame_pose_in_map =
              node_data.global_pose_2d *
              transform::Project2D(constraint_pose.zbar_ij).inverse();

那么 fixed_frame_pose_in_map 就表示GPS系相对于 Global系的位姿了。也就是说
fixed_frame_pose_in_map 表示GPU第一个数据在global系下的位姿,计算出来之后,fixed_frame_pose_initialized 赋值为 true,就不会再为该轨迹计算 fixed_frame_pose_in_map 了。求得 fixed_frame_pose_in_map 会被添加到 C_fixed_frames 之中。

六、GPS残差-残差构建

最后就是最核心的残差构建部分了,主体代码如下所示:

      // Step: 第五种残差 节点与gps坐标系原点在global坐标系下的相对坐标变换 与 通过gps数据进行插值得到的相对坐标变换 的差值作为残差项
      problem.AddResidualBlock(
          CreateAutoDiffSpaCostFunction(constraint_pose),
          options_.fixed_frame_pose_use_tolerant_loss()
              ? new ceres::TolerantLoss(
                    options_.fixed_frame_pose_tolerant_loss_param_a(),
                    options_.fixed_frame_pose_tolerant_loss_param_b())
              : nullptr,
          C_fixed_frames.at(trajectory_id).data(), // 会自动调用AddParameterBlock
          C_nodes.at(node_id).data());
    }

constraint_pose 是利用GPS数据插值出来当前遍历节点 node_it 对应的位姿。ceres::TolerantLoss() 同样用于残差结果的抑制异常点。根据 C_fixed_frames.at(trajectory_id).data() 表示GPU系在global系下的位姿,
C_nodes.at(node_id).data()) 表示当前节点在global系下的位姿,那么可以求得当前节点相对于GPS系的位姿。再与约束 constraint_pose 做残差。

七、开始优化,且更新数据

  // Solve. 进行求解
  ceres::Solver::Summary summary;
  ceres::Solve(
      common::CreateCeresSolverOptions(options_.ceres_solver_options()),
      &problem, &summary);

  // 如果开启了优化的log输出, 就输出ceres的报告
  if (options_.log_solver_summary()) {
    LOG(INFO) << summary.FullReport();
  }

  // 将优化后的所有数据进行更新 Store the result.
  for (const auto& C_submap_id_data : C_submaps) {
    submap_data_.at(C_submap_id_data.id).global_pose =
        ToPose(C_submap_id_data.data);
  }
  for (const auto& C_node_id_data : C_nodes) {
    node_data_.at(C_node_id_data.id).global_pose_2d =
        ToPose(C_node_id_data.data);
  }
  for (const auto& C_fixed_frame : C_fixed_frames) {
    trajectory_data_.at(C_fixed_frame.first).fixed_frame_origin_in_map =
        transform::Embed3D(ToPose(C_fixed_frame.second));
  }
  for (const auto& C_landmark : C_landmarks) {
    landmark_data_[C_landmark.first] = C_landmark.second.ToRigid();
  }

八、结语

到这里位姿,可以说后端优化或者位姿图的讲解基本就完成了,总的来说,后端优化中共有五种残差,分别如下所示:

1、基于节点与子图(子图内,子图间)约束的残差
2、基于Landmark的残差
3、基于odometry里程计的残差
4、基于节点local系下的残差
5、基于GPS数据的残差

下一篇博客,就带代价对后端优化进行一个整理的复盘,让大家的理解更加

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

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

Java - 异常处理

异常介绍 对异常进行捕获&#xff0c;保证程序可以继续运行&#xff0c;提升程序的健壮性。 执行过程中所发生的异常时间可分为两大类&#xff1a; Error&#xff1a; Java虚拟机无法解决的严重问题。如&#xff1a;JVM系统内部错误&#xff0c;资源耗尽等严重情况。比如&…

mysql的一些练习题

1. 第1题 mysql> create database Market charset utf8; Query OK, 1 row affected (0.01 sec)第二题 mysql> use Market Database changed mysql> mysql> create table customers(-> c_num int(11) primary key auto_increment,-> c_name varchar(50),-&…

mac怎么把m4a转换成mp3?

mac怎么把m4a转换成mp3&#xff1f;大家都知道m4a是苹果公司专属的音频文件格式&#xff0c;因此它是可以直接在mac电脑上打开播放的&#xff0c;但这并不代表m4a音频文件可以在其他播放器或者播放设备上直接打开和使用&#xff0c;相信这个问题大家都遇到过&#xff0c;造成这…

黑马头条-day02

文章目录 前言一、文章列表加载1.1 需求分析1.2 表结构分析1.3 导入文章数据库1.4 实现思路1.5 接口定义1.6 功能实现 二、freemarker2.1 freemarker简介2.2 环境搭建&&快速入门2.2.1 创建测试工程 2.3 freemarker基础2.3.1 基础语法种类2.3.2 集合指令2.3.3 if指令2.3…

使用LiteSpeed缓存插件将WordPress优化到100%的得分

页面速度优化应该是每个网站所有者的首要任务&#xff0c;因为它直接影响WordPress SEO。此外&#xff0c;网站加载的时间越长&#xff0c;其跳出率就越高。这可能会阻止您产生转化并为您的网站带来流量。 使用正确的工具和配置&#xff0c;缓存您的网站可以显着提高其性能。因…

【Spark_BigData】期末复习考试——

复习题目 yarn框架中不包含的进程为 Yarn包括两个主要进程:资源管理器Resource-Manager,节点管理器Node-Manager。 Scheduler zookeeper spark SQL 前身 Shark 在Spark中,DataFrame是一种以RDD为基础的分布式数据集,类似于传统数据库中的二维表格。 HiveContext继承自SQLCon…

Jvisualvm内存模型剖析-JVM(五)

上篇文章代码讲解了tomcat加载以及gc回收流程。 Jvm内存模型剖析优化-JVM&#xff08;四&#xff09; Jvisualvm 我们可以编写如上代码&#xff0c;之后打开jvm自带的工具jvisualvm。 如果我们看visual不会明显变化&#xff0c;则可以修改睡眠参数&#xff0c;时间改小。 当…

Vue中的el-date-picker时间选择器的使用

1、value-format属性设置需要什么格式的时间 2、type类型选择datetime、date 年月日时分秒 <el-date-pickervalue-format"yyyy-MM-dd HH:mm:ss"v-model"excelRuleForm.startTime"type"datetime":placeholder"选择开始时间"> &…

【尚医通】vue3+ts前端项目开发笔记——项目分析

尚医通开发笔记 一、项目分析 项目在线地址&#xff1a;http://syt.atguigu.cn测试帐号&#xff1a;17720125002 首页 home header 全局组件布局 左&#xff1a;logo 、title中&#xff1a;初始隐藏 搜索框 公共组件显示条件&#xff1a;在页面滚动到页面内搜索框的位置显示…

爬虫入门04——requests库中的User-Agent请求头

import requests#定义请求的url url https://www.baidu.com/ #https://site.ip138.com/www.xicidaili.com/#发起get请求 res requests.get(url url)#获取响应结果#响应对象 print(res)#获取响应状态码 print(res.status_code)#获取响应数据 print(res.text) #返回的是字符…

mysql 关于用户的练习

环境&#xff1a; (1) 创建用户 create user account1localhost identified by oldpwd1; 授予权限&#xff1a; #给表授予权限 grant select,insert on Team.player to account1localhost identified by oldpwd1;#给info字段授予update权限 grant update(info) on Team.pl…

强化学习路径优化:基于Q-learning算法的机器人路径优化(MATLAB)

一、强化学习之Q-learning算法 Q-learning算法是强化学习算法中的一种&#xff0c;该算法主要包含&#xff1a;Agent、状态、动作、环境、回报和惩罚。Q-learning算法通过机器人与环境不断地交换信息&#xff0c;来实现自我学习。Q-learning算法中的Q表是机器人与环境交互后的…

综合实验---基于卷积神经网络的目标分类案例

文章目录 配置环境猫狗数据分类建模猫狗分类的实例基准模型猫狗分类的实例基准模型之数据增强问题回答 配置环境 ①首先打开 cmd&#xff0c;创建虚拟环境。 conda create -n tf1 python3.6如果报错&#xff1a;‘conda’ 不是内部或外部命令,也不是可运行的程序 或批处理文件…

C语言学习(三十五)---动态内存练习题与柔性数组

经过前面的内容&#xff0c;我们已经对动态内存的知识已经有了相当多了了解&#xff0c;今天我们再做几道有关动态内存的练习题&#xff0c;然后再介绍一下柔性数组&#xff0c;好了&#xff0c;话不多说&#xff0c;开整&#xff01;&#xff01;&#xff01; 动态内存练习题…

【真题解析】系统集成项目管理工程师 2021 年上半年真题卷(案例分析)

本文为系统集成项目管理工程师考试(软考) 2021 年上半年真题&#xff08;全国卷&#xff09;&#xff0c;包含答案与详细解析。考试共分为两科&#xff0c;成绩均 ≥45 即可通过考试&#xff1a; 综合知识&#xff08;选择题 75 道&#xff0c;75分&#xff09;案例分析&#x…

Pycharm使用Anoconda配置虚拟环境

目录 1.Anoconda的介绍 2.Anaconda的作用 3.Anaconda的安装 4.Anaconda的配置 4.1添加镜像源 4.2创建、使用并切换虚拟环境 5.pycharm的集成 1.Anoconda的介绍 Anaconda是一个可用于科学计算的 Python 发行版&#xff0c;可以便捷获取和管理包&#xff0c;同时对环境进行…

Docker 常用指令集合,更换镜像(Ubantu)

1.更换镜像 先进入root用户 cat /etc/docker/daemon.json 查看有没有镜像创建目录,创建并编辑damon,json文件 mkdir -p /etc/docker vim /etc/docker/daemon.json# 填写内容 {"registry-mirrors": ["https://h5rurp1p.mirror.aliyuncs.com"] } 重新启…

postgresql(一):使用psql导入数据库

使用psql导入数据库 1、概述2、具体问题3、总结 1、概述 大家好&#xff0c;我是欧阳方超。 听说postgresql越来越流行了&#xff1f;psql是一个功能强大的命令行工具&#xff0c;用于管理和操作PostgreSQL数据库。它提供了一个交互式环境&#xff0c;允许用户执行SQL查询、创…

PyTorch示例——ResNet34模型和Fruits图像数据

PyTorch示例——ResNet34模型和Fruits图像数据 前言导包数据探索查看数据集构建构建模型 ResNet34模型训练绘制训练曲线 前言 ResNet34模型&#xff0c;做图像分类数据使用水果图片数据集&#xff0c;下载见Kaggle Fruits Dataset (Images)Kaggle的Notebook示例见 PyTorch——…

部署 CNI网络组件

部署 flannel K8S 中 Pod 网络通信&#xff1a; ●Pod 内容器与容器之间的通信 在同一个 Pod 内的容器&#xff08;Pod 内的容器是不会跨宿主机的&#xff09;共享同一个网络命令空间&#xff0c; 相当于它们在同一台机器上一样&#xff0c;可以用 localhost 地址访问彼此的端…