目录

Nucleus Navigator

概述

Nucleus Navigator 使得使用网页浏览器、通过 Omniverse Launcher 中的 Nucleus 标签页,或使用独立的 Nucleus Navigator 应用程序浏览存储在 Nucleus 中的数据变得简单。使用独立的 Nucleus Navigator 应用程序将提供最全面和功能丰富的体验。

无论是添加新内容、调整权限还是分享内容URL,Nucleus Navigator 都为任何 Nucleus 主机提供了便捷访问文件、用户和权限的方式。

Isaac Sim Assets

Isaac Sim Assets Overview

全宇宙内容概述

全宇宙的一般内容可以在您的核心服务器上的 NVIDIA 全宇宙挂载点找到。在此挂载点中,您可以找到各种有用的资源,从纹理到高动态范围图像(HDRI)天空再到动画模型。每一份内容都可以帮助您打造新颖的场景,同时您也可以通过全宇宙的学习之旅逐渐了解其中的奥秘。

有关此内容的更多信息,请参阅全宇宙挂载点内容。

Isaac Sim 内容

样例资源可在全宇宙 Isaac Sim 发布版中下载。要使用这些内容,您必须将文件下载到核心服务器上,或在核心服务器上创建一个 Isaac 挂载点。下面的所有资源路径都默认相对于您的核心服务器。

有关如何安装此内容的更多信息,请参阅 Isaac Sim 初次运行 或样例资源。

内容分类

支持的 usd 可分为四类:

  • 环境包括房间、仓库和其他场景。
  • 机器人包括空中机器人、腿部机器人和轮式机器人,如 Carter,以及像 Franka 这样的机器人 manipulators。
  • 传感器包括真实生活中的传感器,如 Hawk 传感器。
  • 道具包括放置在您的场景中的物品,如角色、其他杂项资源和四月标签。

精选资产包括选定的 Isaac Sim 资产的首页。

警告

第一次访问资源时,资源加载时间会较长;机器人可能需要多分钟才能加载,而较大的环境场景可能需要长达十分钟甚至更长的时间。

3.1. Isaac Sim Extension Templates

3.1.1. Learning Objectives

这个教程提供了使用Extension Template Generator创建可用扩展模板的概览。在本教程结束时,读者将会:

  1. 理解所提供的扩展模板的基本结构。
  2. 理解每个提供的扩展模板的目的。
  3. 能够立即开始编写具有热重新加载功能的功能性代码,以与USD世界进行交互。
  4. 能够在Isaac Sim中创建一个高度定制的基于UI的扩展。

3.1.2. Getting Started

3.1.3. General Concepts

Extension Template Generator提供的每个模板都有一个共同的基本结构,顶部有一层薄薄的实现层。在每个模板的根目录中,都有一个名为./scripts的文件夹,其中存储了支持扩展的所有Python代码。在./scripts中,有三个常见的Python文件:

  • global_variables.py

    这个脚本存储了用户在Extension Template Generator中创建扩展时指定的全局变量,如标题和描述。

  • extension.py

    这是一个包含标准样板代码的类,用于使用户扩展显示在工具栏上。这个类旨在满足大多数用例,无需修改。在extension.py中,创建了一些有用的标准回调函数,用户可以在ui_builder.py中完善。

  • ui_builder.py

    这个文件是用户进入模板的主要入口点。在这里,用户可以看到为他们设置的有用回调函数,他们还可以创建与用户定义的回调函数连接的UI元素。这个文件有最详细的文档,用户应该在进行重大修改之前仔细阅读它。

一个典型的用户只需要修改./scripts/ui_builder.py就可以使他们的扩展按照他们的意愿工作。在./scripts/ui_builder.py中,用户会找到一组与模拟器连接的标准回调函数:

  • on_menu_callback(): 打开扩展时调用

  • on_timeline_event(): 当时间轴停止、暂停或播放时调用

  • on_physics_step(): 每个物理步骤都会调用。物理步骤只会在时间轴播放时发生。

  • on_stage_event(): 当阶段打开或关闭时调用

  • cleanup(): 当资源(如物理订阅)应该被清理时调用,因为扩展正在关闭

  • build_ui(): 用户创建他们想要的UI的函数。

在提供的扩展模板中,大多数实现都在build_ui()函数中。扩展模板利用了一组围绕omni.ui元素的包装类,允许用户轻松创建和管理各种UI元素。本教程中将这些称为UIElementWrappers。每个包装器都旨在为用户提供与UI元素交互的最直观的方式。例如,用户可以创建一个FloatField UI元素;每当用户在UI中修改FloatField时,都会调用一个用户回调函数,并传入新的浮点值。

每个扩展模板在build_ui()中构建一个UI,并包含一组管理回调函数。这些回调函数包含了使UI顺利运行并轻松连接用户代码的所有逻辑,用于自定义应用程序。

3.1.4. Loaded Scenario Template

加载的场景模板为用户提供了一个简单的UI,其中包含三个按钮:Load(加载)、Reset(重置)和Run(运行)。这旨在尽可能清晰地为用户提供开始编写代码直接影响USD阶段的途径,而无需深入了解底层模拟器的内部工作原理。用户只需要了解以下简单的概念:

3.1.4.1. Important Concepts

在Omniverse Kit应用程序中,左侧工具栏上有一个模拟时间轴,可以直接停止、暂停和播放。物理仅在时间轴处于活动状态(非停止状态)时运行。因此,在时间轴停止时,用户无法控制机器人关节,并且在时间轴从停止状态切换到播放状态时,需要对某些资产(如关节)进行初始化。Loaded Scenario模板的目的是让用户更轻松地与模拟器交互,而无需处理初始化等问题。

在omni.isaac.core.world中,有一个设计用于设置和正确管理模拟的单例类World,具有简单清晰的用户交互。在此模板中,World由Load和Reset按钮管理,为用户提供了关于模拟器状态的清晰保证,确保在调用其回调函数时,模拟器处于正确的状态。用户与World的交互被最小化,仅需以world.scene.add(user_object)的形式将用户对象添加到场景中,其中user_object是来自omni.isaac.core的任何对象。

为了确保正常功能,所有对时间轴的操作应该由Load和Reset按钮完成。也就是说,用户可以通过按下左侧工具栏上的停止和播放按钮来制造麻烦,超出了该UI的范围。因此,模板直接处理了用户在模板UI之外干扰时间轴的情况,必要时重置UI以维持对用户回调函数的假设。

3.1.4.2. Implementation Details

Load按钮有两个回调函数:

  • setup_scene_fn()

    按下Load按钮后,会创建World的一个新实例,然后调用此函数。用户现在应该将他们的资源加载到舞台上,并使用world.scene.add()将它们添加到World中。

  • setup_post_load_fn()

    用户可以假设他们的资源已经被setup_scene_fn()回调函数加载,他们的对象已经被正确初始化,并且时间轴在时间步0上暂停。

Reset按钮有两个回调函数:

  • pre_reset_fn()

    在重置World之前调用此函数,因此对模拟器的状态没有保证。

  • post_reset_fn()

    用户可以假设他们的对象已经被正确初始化,并且时间轴在时间步0上暂停。

    他们还可以假设已将添加到World中的对象移回到它们的默认位置。也就是说,一个cube prim会回到它在setup_scene_fn()中创建时的位置。

RUN按钮没有连接到World。它是一个StateButton,这意味着它将在两个状态之间切换:“RUN”和“STOP”。StateButton可以有三个回调函数:

  • on_a_click()

    当StateButton显示其a_text时调用的函数。

  • on_b_click()

    当StateButton显示其b_text时调用的函数。

  • physics_callback_fn()

    如果指定了,StateButton将在其处于B状态时在每个物理步骤调用此函数,并且当StateButton处于其A状态时,它将取消物理订阅。

4.1. Hello World

NVIDIA Omniverse™ Kit是Omniverse Isaac Sim用来构建其应用程序的工具包,提供了一个用于脚本编写的Python解释器。这意味着每个GUI命令以及许多附加功能都可作为Python API使用。然而,使用Pixar的USD Python API来与Omniverse Kit进行交互的学习曲线陡峭,步骤经常繁琐。因此,我们提供了一组设计用于在机器人应用程序中使用的API,这些API抽象出了USD API的复杂性,并将多个步骤合并为一个以执行频繁的任务。

在本教程中,我们将介绍核心API的概念以及如何使用它们。我们将从将一个立方体添加到空舞台开始,并在此基础上构建,创建一个同时执行多个任务的多机器人场景,如下所示。

4.1.1. Learning Objectives

这个教程系列介绍了核心API。在本教程之后,你将学会:

  • 根据核心API创建世界和场景。
  • 如何在Omniverse Isaac Sim中使用Python添加刚性体到舞台并进行模拟。
  • 在扩展工作流程与独立工作流程以及在Jupyter Notebook中运行Python的区别。

10-15分钟教程

4.1.2. Getting Started

先决条件:

本教程需要对Python和异步编程有中级知识。
在开始本教程之前,请下载并安装Visual Studio Code。
在开始本教程之前,请查阅Isaac Sim界面和Isaac Sim工作流程。

首先打开“Hello World”示例。转到顶部菜单栏,点击Isaac Examples > Hello World。

现在,Hello World示例扩展的窗口应该在工作区中可见。点击“Open Source Code”按钮,在Visual Studio Code中打开源代码以进行编辑。
点击“Open Containing Folder”按钮以打开包含示例文件的目录。该文件夹包含三个文件:hello_world.py、hello_world_extension.py和__init__.py。

hello_world.py脚本是应用程序逻辑的添加位置,而应用程序的UI元素将在hello_world_extension.py脚本中添加,并与逻辑相连接。

  • 点击“LOAD”按钮加载世界。
  • 点击“File” > “New From Stage Template” > “Empty”来创建一个新的舞台,在提示保存当前舞台时点击“Don’t Save”。
  • 再次点击“LOAD”按钮加载世界。
  • 打开hello_world.py并按下“Ctrl+S”以使用热重载功能。你会注意到菜单从工作区中消失了(因为它被重新启动了)。
  • 再次打开示例菜单并点击“LOAD”按钮。

现在你可以开始向这个示例添加内容了。

4.1.3. Code Overview

该示例继承自BaseSample,这是一个样板扩展应用程序,为每个机器人扩展应用程序设置了基本框架。以下是BaseSample执行的一些操作示例:

  • 使用按钮加载世界及其相应的资产
  • 当创建新舞台时清除世界
  • 将世界中的对象重置为它们的默认状态
  • 处理热重载

World是一个核心类,它使您能够以简单和模块化的方式与模拟器进行交互。它处理许多与时间相关的事件,例如添加回调、执行物理步骤、重置场景、添加任务(稍后将在添加一个操纵机器人中介绍),等等。

世界包含一个场景的实例。Scene类管理USD阶段中感兴趣的模拟资产。它提供了一个简单的API,用于在阶段中添加、操作、检查和重置不同的USD资产。

4.1.3.1. Singleton World

World是一个单例(Singleton),这意味着在运行Omniverse Isaac Sim时只能存在一个World实例。下面的代码演示了如何在不同的文件和扩展中检索当前World实例。

4.1.4. Adding to the Scene

接下来,使用Python API将一个立方体添加到场景中作为刚体。
按下Ctrl+S保存代码并热重载Omniverse Isaac Sim。

再次打开菜单。

点击“File” > “New From Stage Template” > “Empty”,然后点击“LOAD”按钮。如果您在setup_scene中进行了任何更改,则需要执行此操作。否则,您只需要按下“LOAD”按钮。

按下“PLAY”按钮开始模拟动态立方体,并观察它下落。

4.1.5. Inspecting Object Properties

接下来,打印出立方体的世界位置和速度。下面的突出显示的行展示了如何使用名称获取对象并查询它们的属性。

4.1.5.1. Continuously Inspecting the Object Properties during Simulation

接下来,在每次执行物理步骤期间,在模拟过程中打印立方体的世界位置和速度。如Isaac Sim工作流程中所述,在这种工作流程中,应用程序是异步运行的,无法控制何时执行物理步骤。但是,您可以添加回调函数以确保在特定事件之前发生某些事情。

添加物理回调如下所示。

4.1.6. Adding a New Example in the Menu

到目前为止,您一直在编辑“Hello World”示例。接下来,您将在Isaac示例菜单下创建一个新示例。

4.1.7. Converting the Example to a Standalone Application

正如在Isaac Sim工作流程中提到的那样,在这个工作流程中,当从Python启动时,机器人应用程序会立即启动,并且您可以控制何时执行物理和渲染步骤。

4.1.8. Converting the example to a Standalone Application using Jupyter Notebook

4.1.9. Summary

本教程涵盖了以下主题:

  • World(世界)和Scene(场景)类的概述。

  • 通过Python向场景添加内容。

  • 添加回调函数。

  • 访问对象的动态属性。

  • 独立应用程序的主要差异。

  • 使用Jupyter开发应用程序时的主要差异。

4.2. Hello Robot

4.2.1. Learning Objectives

本教程详细介绍了如何在Omniverse Isaac Sim的扩展应用程序中添加和移动移动机器人。完成本教程后,您将了解如何向仿真中添加机器人,并使用Python对其轮子执行操作。

4.2.2. Getting Started

先决条件

在开始本教程之前,请回顾“Hello World”。

从上一个教程“Hello World”中开发的Awesome Example的源代码开始。

4.2.3. Adding a Robot

首先通过Python向场景中添加一个NVIDIA Jetbot,这样您就可以访问Omniverse Isaac Sim机器人、传感器和环境的库,这些资源位于Omniverse Nucleus服务器上,并且可以使用Content窗口进行导航。

注意
在这些步骤中显示的服务器已经在Isaac Sim首次运行中连接。请先按照这些步骤进行。
通过简单地将资产拖动到舞台窗口或视口中来添加这些资源。

4.2.4. Move the Robot

在Omniverse Isaac Sim中,机器人由物理精确的关节构成。对这些关节施加动作可以使它们移动。

接下来,对Jetbot的关节控制器应用随机速度,使其开始移动。

4.2.4.1. Extra Practice

这个示例将随机速度应用于Jetbot的关节控制器。尝试以下练习:

  • 让Jetbot向后移动。
  • 让Jetbot向右转。
  • 让Jetbot在5秒钟后停止。

4.2.5. Using the WheeledRobot Class

Omniverse Isaac Sim还具有特定于机器人的扩展,提供进一步定制的功能,并访问其他控制器和任务(稍后会详细介绍)。现在,您将使用WheeledRobot类重新编写前面的代码,使其更简单。

4.2.6. Summary

本教程涵盖了以下主题:

  • 从Nucleus服务器添加Omniverse Isaac Sim库组件
  • 将机器人添加到世界中
  • 使用关节级控制器来控制机器人的关节运动
  • 使用机器人类
  • 使用机器人特定的扩展

4.3. Adding A Controller

4.3.1. Learning Objectives

本教程描述了如何创建和使用自定义控制器来移动移动机器人。然后,它介绍了如何使用Omniverse Isaac Sim中可用的控制器。完成本教程后,您应该更加熟悉在Omniverse Isaac Sim中添加和控制机器人。

4.3.2. Getting Started

4.3.3. Creating a Custom Controller

首先,编写一个使用双轮差速驱动的单车模型的开环控制器。在Omniverse Isaac Sim中,控制器都继承自BaseController接口。您需要实现一个forward方法,并且它必须返回一个ArticulationAction类型。

4.3.4. Using the Available Controllers

Omniverse Isaac Sim还在许多机器人扩展中提供了不同的控制器。重新编写以前的代码,使用DifferentialController类,并添加一个WheelBasePoseController。

4.3.5. Summary

本教程涵盖了以下主题:

  • 创建一个自定义控制器来移动移动机器人
  • 使用Omniverse Isaac Sim中的控制器类

4.4. Adding a Manipulator Robot

4.4.1. Learning Objectives

本教程介绍了第二个机器人——Franka Panda manipulator,如何将机器人添加到场景中,并使用其PickAndPlaceController类。完成本教程后,您将更有经验地使用Omniverse Isaac Sim中的不同机器人和控制器类。

4.4.2. Getting Started

4.4.3. Creating the Scene

首先从omni.isaac.franka扩展添加一个Franka机器人,以及一个用于Franka抓取的立方体。

4.4.4. Using the PickAndPlace Controller

添加一个来自omni.isaac.franka的pick-and-place控制器,使Franka机器人可以捡起立方体并将其放置到其他位置。

4.4.5. What is a Task?

Omniverse Isaac Sim中的Task类提供了一种将场景创建、信息检索和计算度量标准模块化的方法。它对于创建具有高级逻辑的复杂场景非常有用。您需要使用Task类重新编写以前的代码。

4.4.6. Use the Pick and Place Task

Omniverse Isaac Sim还在许多机器人扩展中提供了不同的任务。重新编写以前的代码,使用PickPlace类。

4.4.7. Summary

本教程涵盖了以下主题:

  • 将操纵机器人添加到场景中。
  • 使用Omniverse Isaac Sim中的控制器类。
  • 通过Task类将场景对象的创建模块化,并通过它们进行交互。
  • 使用Omniverse Isaac Sim中机器人扩展中的Task类。

4.5. Adding Multiple Robots

4.5.1. Learning Objectives

本教程将两种不同类型的机器人集成到同一个仿真中。它详细介绍了如何构建程序逻辑来在子任务之间进行切换。完成本教程后,您将具有构建更复杂的机器人交互仿真的经验。

4.5.2. Getting Started

4.5.3. Creating the Scene

首先将来自先前教程的Jetbot、Franka Panda和立方体添加到场景中。使用任务中的子任务来简化代码。

4.5.4. Integrating a Second Robot

接下来,驱动Jetbot将立方体推向Franka。

4.5.5. Adding Task Logic

接下来,当Jetbot将立方体交付后,将Jetbot向后驱动,以给Franka留出空间来捡起立方体。

4.5.6. Robot Handover

现在轮到Franka去捡起它了。

4.5.7. Summary

本教程涵盖了以下主题:

  • 向场景添加多个机器人
  • 使用任务结构中的程序状态控制机器人行为

5.7. Adding a New Manipulator

5.7.1. Learning Objectives

本教程将演示如何从一个代表机械手机器人的URDF开始,逐步调整它,添加一个跟随目标示例,最后执行一个拾取放置任务,将一个新的机械手添加到Isaac Sim中。

完成本教程后,您将能够在Omniverse Isaac Sim中为一个新的机械手创建一个拾取放置任务以及其控制器。在本教程中,我们将使用Denso的cobotta pro 900机器人。

请注意,本教程中创建的所有文件都可以在 standalone_examples/api/omni.isaac.manipulators/cobotta_900 中进行验证。

30-35分钟教程

5.7.3. Creating the Robot USD file from URDF

5.7.3.1. Using the URDF Importer

让我们通过使用Omniverse Isaac Sim提供的urdf导入工具来导入urdf,从而创建USD文件。

将urdf资源(cobotta_pro_900.urdf、cobotta_pro_900_visualization和onrobot_rg6_visualization)复制到一个新文件夹URDF_Folder_Path/cobotta_pro_900下。这些资源可以在/extscache/omni.importer.urdf-*/data/urdf/robots/cobotta_pro_900中找到。

使用启动器启动Omniverse Isaac Sim。

通过Isaac Utils > Workflows > URDF Importer打开URDF导入器。

在扩展窗口中,选择Import > Input File下的URDF文件,勾选Create Physics Scene和Fix Base Link,选择Joint Drive Type为Position,并取消勾选Parse Mimic Joint标签。

在扩展窗口中点击Import。

关闭URDF Importer扩展窗口。

检查是否在URDF_Folder_Path/cobotta_pro_900/cobotta_pro_900/cobotta_pro_900.usd下创建了一个USD文件。请注意,它被添加为舞台的一个引用,这意味着您无法修改和保存对原始USD的引用。要这样做,必须打开USD文件,而不是将其添加为引用。

在左侧点击播放按钮(确保此时控制台上没有错误信息输出)。

要了解有关URDF导入器的更多信息,请参阅Import URDF教程和URDF导入器手册。

5.7.3.2. Inspect the Articulation

确保关节对位置控制是响应的。

  1. 通过File > New创建一个新舞台。一个defaultLight会被添加到新舞台中。

  2. 在World下创建一个XForm,并将其重命名为denso。

  3. 将机器人USD作为一个引用添加到新的denso xform中。

  4. 在舞台上添加一个物理场景。

  5. 通过Isaac Utils > Workflows > Articulation Inspector打开Articulation Inspector,然后点击播放按钮。

  6. 在Select Articulation下选择对应于关节的prim_path,值为World/denso。

  7. 在DOF View下熟悉关节中的自由度。注意每个自由度的目标位置都为0,夹爪有6个关节。此外,注意夹爪自由度的名称,以便与Omniverse Isaac Sim提供的Parallel Gripper类一起使用,为了简化拾取和放置控制器,我们只使用两个关节进行夹爪控制(具体来说,我们将使用“finger_joint”和“right_outer_knuckle_joint”来夹取物体,我们将将dof 7和8设置为0.628,这样当其他关节夹取立方体时它们就不会妨碍)。进一步检查这些自由度,以更好地理解夹爪。

  8. 尝试使用滑块更改目标位置,并确保自由度位置达到指定的目标。

  9. 关闭Articulation Inspector扩展窗口。

要了解更多关于Articulation Inspector的使用信息,请参阅Articulation Inspector手册。

5.7.3.3. Gains Tuning

接下来,我们调整刚度和阻尼增益,使cobotta机器人在达到目标位置时具有平滑稳定的运动。

  1. 打开位于URDF_Folder_Path/cobotta_pro_900/cobotta_pro_900/cobotta_pro_900.usd的USD文件。请注意,我们没有将其添加为引用,以便能够保存新的增益到USD文件中。

  2. 通过Create > Physics > Physics Scene添加一个物理场景。

  3. 通过Isaac Utils > Workflows > Gain Tuner打开Gain Tuner,并点击播放按钮。

  4. 在Select Articulation下选择对应于关节的prim_path,值为World/denso。

  5. 导航到Test Joint Positions面板,点击“Send Position Targets”旁边的“Start”以开始发送新的位置目标给denso机器人。然后点击“Randomize”旁边的“Randomize Position Targets”。

  6. 验证机械臂和夹爪到达目标关节位置的运动是否平稳稳定。如果不满意运动,请通过导航到Gains面板并指定刚度倍增器和阻尼倍增器,然后点击“SCALE”来缩放阻尼和刚度。

  7. 为了本教程的目的,将默认增益分别缩放为0.0001和0.001作为刚度和阻尼倍增器,使得结果的刚度和阻尼分别为10000和10000。

  8. 如果满意关节到目标位置的运动,请保存USD文件并继续下一步;否则返回上一步(不要忘记在保存USD文件之前删除物理场景)。注意:对于cobotta_pro_900机器人,简单的拾取放置不需要调整。

  9. 关闭Gain Tuner扩展窗口。

要了解更多有关Gain Tuner的使用信息,请查阅Gain Tuner手册。

5.7.4. Controlling the Gripper

接下来,我们开始一个简单的脚本,其中我们重复打开和关闭夹爪,以验证我们可以加载USD文件并进行简单的控制。

  1. 在一个新文件夹COBOTTA_SCRIPTS_PATH/gripper_control.py下创建一个新的python文件(注意:此示例使用独立的工作流程)。

  2. 使用 ./python.sh standalone_examples/api/omni.isaac.manipulators/cobotta_900/gripper_control.py 运行它。

5.7.5. Adding a Follow Target Task

接下来,我们添加一个跟随目标任务,为cobotta机器人提供一个目标立方体,让其用末端执行器跟随。

  1. 在一个新的文件夹COBOTTA_SCRIPTS_PATH/tasks/follow_target.py下创建一个新的python文件来定义任务类。
  2. 在COBOTTA_SCRIPTS_PATH下创建一个新的python文件follow_target_example.py来运行跟随目标示例。
  3. 使用 ./python.sh standalone_examples/api/omni.isaac.manipulators/cobotta_900/follow_target_example.py 运行它。

接下来,我们尝试使用Omniverse Isaac Sim中提供的简单逆向运动学求解器来跟随目标。我们需要将求解器指向urdf和机器人描述文件,因此让我们在COBOTTA_SCRIPTS_PATH/rmpflow/robot_descriptor.yaml创建一个新的yaml文件。

将以下内容复制到机器人描述文件中。

# Robot Descriptor File for COBOTTA robot

urdf: "path/to/your/urdf_file.urdf"
description: "path/to/your/robot_description.yaml"
  1. 在COBOTTA_SCRIPTS_PATH/ik_solver.py中创建一个新的python文件来定义IK求解器类。
  2. 修改COBOTTA_SCRIPTS_PATH/follow_target_example.py脚本,使用IK求解器跟随目标。
  3. 使用 ./python.sh standalone_examples/api/omni.isaac.manipulators/cobotta_900/follow_target_example.py 运行它。

5.7.6. Using RMPflow to Control the Robot

接下来,我们尝试使用RMPFlow来跟随目标,所以让我们在COBOTTA_SCRIPTS_PATH/rmpflow下创建一个新的yaml文件denso_rmpflow_common.yaml,并将以下内容复制到其中。

# RMPFlow Configuration File for COBOTTA robot

rmpflow:
  robot_name: "cobotta_pro_900"
  urdf: "path/to/your/urdf_file.urdf"
  description: "path/to/your/robot_description.yaml"

接着,在一个新的文件夹COBOTTA_SCRIPTS_PATH/controllers/rmpflow.py下创建一个新的python文件,用来定义RMPFlow控制器类。

最后,修改COBOTTA_SCRIPTS_PATH/follow_target_example.py脚本,使用RMPFlow来跟随目标。

最后,使用 ./python.sh standalone_examples/api/omni.isaac.manipulators/cobotta_900/follow_target_example.py 来运行它。

5.7.7. Adding a PickPlace Task

接下来,我们添加一个拾取放置任务,为cobotta机器人提供一个立方体,让它在目标位置进行拾取和放置。

  1. 在一个新的文件夹COBOTTA_SCRIPTS_PATH/tasks/pick_place.py下创建一个新的python文件来定义任务类。
  2. 在COBOTTA_SCRIPTS_PATH下创建一个新的python文件pick_place_example.py来运行拾取放置示例。
  3. 使用 ./python.sh standalone_examples/api/omni.isaac.manipulators/cobotta_900/pick_place_example.py 运行它。

5.7.8. Adding a PickPlace Controller

接下来,我们尝试使用Omniverse Isaac Sim中提供的简单拾取放置控制器来拾取和放置立方体。在COBOTTA_SCRIPTS_PATH/controllers下创建一个新的python文件pick_place.py,并将以下内容复制到其中。

接着,修改COBOTTA_SCRIPTS_PATH/pick_place_example.py文件以使用cobotta运行拾取放置例程。

最后,使用 ./python.sh standalone_examples/api/omni.isaac.manipulators/cobotta_900/pick_place_example.py 来运行它。

5.7.9. Summary

本教程涵盖了以下主题:

  • 使用URDF导入器导入机器人。
  • 使用关节检查器检查机器人USD。
  • 使用增益调节器调整机器人增益。
  • 添加跟随目标以及拾取放置任务。
  • 添加解决指定任务的控制器。
  • 添加简单的RMPFlow配置文件。

6.1. Lula Robot Description Editor

6.1.1. Learning Objectives

该教程介绍了如何使用机器人描述编辑器UI工具生成所有Lula算法所需的robot_description.yaml配置文件。该教程描述了需要特定配置文件以配合Lula算法的动机,并介绍了为每个可用的Lula算法写入robot_description.yaml文件所需的最小数据集。

然后,该教程展示了如何使用机器人描述编辑器UI工具自动将适当的信息写入robot_description.yaml文件,或编辑预先存在的robot_description.yaml文件。

6.1.2. What is in a Robot Description File?

机器人描述文件是与机器人URDF一起使用所有Lula算法所需的主要配置文件。创建robot_description.yaml文件是用户希望在新机器人上使用Lula算法时必须进行的第一个且最耗时的步骤。

6.1.2.1. Defining the Robot C-Space: Active and Fixed Joints

机器人描述文件的一个关键方面是定义机器人的c空间。例如,假设我们有一个7自由度的机器人 manipulator,例如附有2自由度夹持器的 Franka arm。在机器人 URDF 文件中,总共有 9 个非固定关节可以被视为可控的。然而,Lula 算法集合(如 RMPflow、Lula RRT、Lula 轨迹生成器等)的设计是将机器人移动到位,而不是控制末端执行器。在典型的使用案例中,我们可能会使用 RMPflow 将机器人末端执行器移动到一个方块上方的位置,然后分别打开和关闭夹持器。

机器人描述文件必须将每个关节区分为“主动关节”或“固定关节”。标记为“主动关节”的任何东西将直接被控制,而标记为“固定关节”的任何东西将被假定从 Lula 算法的角度来看是固定的。在使用 RMPflow 在 Franka 机器人上的情况下,Franka 臂上的七个关节被标记为“主动关节”,夹持器关节被标记为“固定关节”。

在机器人描述编辑器中,必须为主动和固定关节选择位置。将“主动关节”的位置视为默认位置。当 RMPflow 没有给出任何目标时,它将使机器人朝向默认位置移动。当给定一个目标时,它将使用“主动关节”的默认位置来解决空间行为;即一个 7 自由度机器人可以通过许多方式到达单个目标,而 RMPflow 将倾向于一个 c 空间位置,该位置接近默认位置。

无法告诉 RMPflow “固定关节”处于除机器人描述文件中写入的位置之外的任何其他位置,因此选择固定关节位置的合理值是很重要的。在 Franka 示例中,夹持器关节位置被赋予一个固定值,对应于夹持器处于打开状态,因为这样最好地促使 RMPflow 避免夹持器和障碍物之间的碰撞,无论夹持器状态如何(当夹持器关闭时,夹持器指在打开夹持器的凸多边形内部)。

6.1.2.2. Collision Spheres

Lula算法使用自定义配置以实现高效的碰撞回避。对于给定的机器人,必须定义一组碰撞球,大致覆盖机器人的表面。Lula算法不允许在机器人描述文件中定义的任何碰撞球与USD世界中的任何障碍物相交。机器人描述编辑器提供了多种工具,允许用户快速为任何机器人定义完整的碰撞球集合。

6.1.3. What Information is Required for Each Lula Algorithm?

不同的 Lula 算法需要机器人描述文件的不同完成级别。每种算法都需要用户适当选择主动和固定关节。然而,只有在使用需要与外部障碍物进行碰撞回避的算法时,才需要配置碰撞球。例如,Lula 运动学求解器纯粹是运动学的,它不与外部世界进行交互。因此,机器人描述文件中可以省略碰撞球表示。RMPflow 可以在没有定义任何碰撞球的情况下运行,但它将无法避免障碍物。

6.1.4. Using the Robot Description Editor

该教程的这一部分包括对机器人描述编辑器 UI 工具中不同面板的简要文本描述,并通过视频形式提供更详细的步骤说明,以捕捉扩展的交互性质。如果生成的机器人描述文件导致不良结果,花时间彻底观看教程视频以了解每个字段将是值得的。

机器人描述编辑器与可实例化资产不兼容,但对于后来转换为可实例化资产的资产生成的机器人描述文件仍将适用于可实例化资产。

6.1.4.1. Getting Started

要开始使用机器人描述编辑器,请在工具栏中选择Isaac Utils -> Lula Robot Description Editor。然后,打开您选择的机器人的 USD 文件,并单击左侧的“播放按钮”。

在选择面板中,一旦机器人在舞台上并且舞台正在播放,将会弹出一个下拉菜单,您可以在其中选择您的机器人。从“选择关节”字段中选择您的机器人关节的主路径。完成此操作后,另一个标记为“选择链接”的下拉菜单将显示出机器人中每个链接的名称。这将在我们使用工具时需要。

我们已经完成了开始制作机器人描述文件的所有必要步骤。其他面板将显示机器人特定信息,然后我们可以继续进行命令面板。

6.1.4.2. Command Panel

一旦从“选择关节”菜单中选择了机器人关节,命令面板将会展开并填充。命令面板要求用户提供生成机器人描述文件所需的关键信息。观看附带的视频或仔细阅读《定义机器人c空间:主动和固定关节》。

在命令面板中,为机器人关节选择一个“关节位置”和一个“关节状态”。请记住以下几点:

  • 只有当用户打算让某个关节由Lula算法直接控制时,才将其标记为“主动关节”。通常,这涉及将机器人手臂中的每个关节标记为主动关节,同时将附加到手臂的操纵器中的关节标记为“固定关节”。至少一个关节必须被标记为“主动关节”。

  • “固定关节”的关节位置可能很重要,这取决于使用情况,并值得一些思考。Lula 将假定“固定关节”的位置是真正固定的;即在运行时无法覆盖这些位置。

  • “主动关节”的位置被认为是机器人的默认配置。这个默认配置被Lula算法的子集使用,主要情况是RmpFlow。应选择一个在机器人前方(按照Isaac Sim中的惯例,沿着+X轴)并且不靠近任何关节极限的默认配置。

6.1.4.3. Adding Collision Spheres

添加碰撞球时,会逐个链接地向机器人添加。用户可以从选择面板的“选择链接”字段中选择感兴趣的链接。链接球编辑器面板包含与所选链接范围内的函数,例如在链接中添加球体、缩放球体和仅清除链接内的球体。编辑器工具面板包含在所选链接范围之外的函数,例如“撤销”和“重做”按钮、更改碰撞球的颜色以及切换机器人的可见性。

当球体添加到链接时,它们被添加到USD舞台上,作为一个嵌套在所选链接下的基元。用户可以通过在舞台上移动球体或更改其半径来点击并修改任何球体。球体相对于包含它的链接的原点的位置被写入机器人描述文件中作为固定值。

有三种主要方法可以将球体添加到链接中:

  1. 添加球体:添加一个具有相对于链接原点的指定相对平移的单个球体。创建后,可以通过修改球体基元来轻松更改此平移。

  2. 连接球体:选择已经在链接下创建的两个球体,并将它们连接起来,连接它们之间指定数量的球体。连接球体的位置和大小会被插值以最佳填充由连接的两个球体定义的锥形部分的体积。

  3. 生成球体:选择定义链接体积的网格,并自动生成一组最佳填充网格体积的 N 个球体。当指定了生成的球体数量时,将自动生成的球体的预览自动显示出来,可以通过单击“生成球体”按钮来最终确定。任何可见的机器人都必须至少有一个定义其链接的网格。当存在多个网格时,最好尝试每个网格,以找出可以生成良好覆盖的最小球体集。通常,最好手动“连接球体”对于具有简单圆柱形状的链接。

6.1.4.4. Saving Robot Description File

在完成命令面板并创建机器人的碰撞球表示后,可以通过使用“导出机器人描述文件”面板轻松导出机器人描述文件。必须选择一个以 .yaml 结尾的文件名,并指定一个本地计算机上的文件路径。当输入了有效的文件路径后,“保存”按钮将变为可用状态。

6.1.4.5. Loading Robot Description File

通过使用“导入机器人描述文件”面板,可以将预先存在的机器人描述文件导入到编辑器中。这将覆盖机器人描述编辑器中的所有信息。

6.1.5. Summary

这个教程展示了如何使用 Lula 机器人描述编辑器来高效生成 Lula 机器人描述文件。这涵盖了不同 Lula 算法所需的大部分或全部配置信息!

6.2. Lula RMPflow

6.2.1. Learning Objectives

这个教程展示了如何使用运动生成扩展中的 RMPflow 类来生成平滑的运动,以达到任务空间目标,并避开动态障碍物。该教程首先展示了如何直接实例化 RmpFlow 并用于生成运动,然后展示了如何在支持的机器人上简单加载和使用 RmpFlow,最后演示了如何使用内置的调试功能来提高易用性和集成性。

6.2.2. Getting Started

先决条件:

请在开始本教程之前完成添加机械臂机器人教程。

请查看加载的场景扩展模板,以了解本教程的结构和运行方式。

为了跟随本教程,您可以在这里下载一个独立的 RMPflow 示例扩展:RMPflow 教程。提供的zip文件提供了一个完全功能的 RMPflow 示例,包括跟随目标、对世界的感知以及调试选项。本教程的各个部分将逐步构建文件 scenario.py,从基本功能到完成的代码。要跟随本教程,您可以下载提供的扩展,并用所提供的代码片段替换 /RmpFlow_Example_python/scenario.py 的内容。

6.2.3. Generating Motions with an RMPflow Instance

RMPflow在Omniverse Isaac Sim中被广泛用于控制机器人 manipulators。正如在RMPflow配置文档中所记录的,直接实例化RmpFlow类需要三个配置文件。一旦加载了这些配置文件并指定了末端执行器目标,就可以计算动作以将机器人移动到所需的目标位置。
RMPflow是运动策略接口的一种实现。可以将任何MotionPolicy传递给Articulation Motion Policy,以开始在USD舞台上移动机器人。在第43行,使用所需的配置信息实例化了一个RmpFlow实例。在第52行创建的ArticulationMotionPolicy acts充当RmpFlow和模拟Franka机器人Articulation之间的转换层。用户可以直接与RmpFlow交互,以通信世界状态、设置末端执行器目标或修改内部设置。在每帧上,末端执行器目标直接传递给RmpFlow对象(第60行)。ArticulationMotionPolicy在第64行被用来计算一个动作,该动作可以直接被Franka Articulation消耗。

7.2. ROS 2 Tutorials

7.2.1. URDF Import: Turtlebot

Omniverse Isaac Sim拥有多种工具,可用于与ROS系统集成。我们有ROS和ROS2桥接器、URDF导入器等等。本教程系列提供了如何使用这些工具的示例。

7.2.1.1. Learning Objectives

在这个示例中,我们将在Isaac Sim中设置一个Turtlebot3,并使其能够四处移动。

如果您已经有一个以USD格式呈现的带有骨骼关节和属性的机器人,并且希望直接使用我们的ROS桥接器,请转到系列中的下一个教程:通过ROS2消息驱动TurtleBot。

7.2.1.2. Getting Started

7.2.1.3. Importing TurtleBot URDF

如果您还没有下载和构建Turtlebot3的描述包,请按照以下步骤操作。您可以在提供的ROS2工作空间内构建它。

git clone -b <distro>-devel https://github.com/ROBOTIS-GIT/turtlebot3.git turtlebot3
  • 在turtlebot3/turtlebot3_description/urdf/turtlebot3_burger.urdf中找到Turtlebot3 Burger的URDF文件。

  • 为了本教程的目的,您只需要构建turtlebot3_description包。随意跳过存储库中的其他软件包。

  • 为了本教程系列的目的,我们将使用Isaac环境,但您可以将机器人导入到您选择的任何环境中。通过转到视口下方的内容选项卡,打开环境,并找到Isaac/Environments/Simple_Room/simple_room.usd。如果您不想使用提供的环境,请确保您的环境中有一个地面平面和一个物理场景。这两者都可以在Create -> Physics中找到。您可能还需要一些照明效果,在Create -> Light中尝试各种类型的照明来获得所需的效果。

  • 在新舞台上,将simple_room.usd拖放到舞台上,并通过将Transform属性中的所有Translate组件归零将其放置在原点。您可能需要放大一点以查看房间内的桌子。

  • 打开URDF导入器Isaac Utils > Workflows > URDF Importer。

  • 在提示窗口内,取消选中清除舞台以保留现有环境,在这是一个移动机器人的情况下,取消选中修复基本链接,将关节驱动类型更改为速度,以便稍后可以正确驱动车轮。

  • 在导入部分内,首先在输入文件中定位要导入的URDF文件。只有在选择文件后,导入按钮才会启用。

  • 一旦资源导入到Omniverse Kit中,资源的.usd版本的副本将自动保存。如果要将资源保存到与.urdf文件所在的文件夹不同的文件夹中,请在输出目录中指定该文件夹。指定目录中将创建一个与.urdf文件名称相匹配的文件夹,并且.usd文件将位于新创建的文件夹中。

  • 确保通过在舞台选项卡内单击空白空间或选择树上的/World来取消选中舞台上的所有内容。否则,您可能会将Turtlebot导入为树上随机对象的子对象。

  • 点击导入。

  • 当首次导入Turtlebot时,它将位于桌子上。使用操作柄将其放置在房间地板的上方。

  • 按播放按钮,您应该看到Turtlebot落到地板上。

7.2.1.4. Tune the Robot

URDF导入器会在Omniverse Isaac Sim中自动导入材质、物理和关节属性,只要它们在系统中可用并且具有匹配的类别。然而,如果两个系统之间没有可用或匹配的类别,或者单位不同,自动填充的内容可能不准确,并且会改变机器人的行为。以下是一些可以调整以纠正机器人行为的属性。

摩擦性质

如果您的机器人的车轮打滑,请尝试根据添加简单对象中的步骤3.4.2更改车轮和地面的摩擦系数。

物理属性

如果没有给出明确的质量或惯性属性,物理引擎将从几何网格估算它们。要更新质量和惯性属性,请找到包含给定链接的刚体的prim(您可以通过在其属性选项卡下查找“Physics > Rigid Body”来验证此刚体)。如果它已经在其物理属性选项卡下具有“Mass”类别,请相应地修改它们。如果还没有“Mass”类别,您可以通过在属性选项卡顶部点击+添加按钮,然后选择“Physics > Mass”来添加它。

关节属性

如果您的机器人在关节处振荡或移动过慢,请查看关节的刚度和阻尼参数。高刚度使关节更快地、更难地跳至目标,而更高的阻尼则使关节运动到目标更加平滑,但也减慢了关节的移动速度。对于纯位置驱动器,将刚度设置相对较高,阻尼设置相对较低。对于速度驱动器,刚度必须设置为零,阻尼设置为非零。

注意

当URDF导入器完成时,出现在舞台上的机器人通常被加载为一个参考。这可以通过舞台树图标上机器人prim上的橙色箭头来确认。如果您在更改参数并保存它们时遇到问题,可能需要编辑参考指向的原始USD文件。要找到指向原始USD文件的文件路径,请导航到属性选项卡,然后转到References > Asset Path。

7.2.2. Driving TurtleBot via ROS2 messages

ROS桥接器提供了一些常用的rostopics,以便于使用打包。更多详细信息请参阅ROS和ROS2桥接器。这里我们将专注于使用它们的流程。

将Omniverse Isaac Sim连接到ROS的步骤可以完全在UI中完成,在扩展工作流中进行脚本编写,或者在独立的Python工作流中进行脚本编写。有关不同工作流的详细信息,请参阅Isaac Sim工作流。首先,我们将演示使用现有的Omnigraph节点的UI方法。其他方法的介绍列在进一步学习部分。

7.2.2.1. Learning Objectives

在这个示例中,我们将使Turtlebot3能够四处移动,并通过ROS网络订阅扭转消息。我们将学习:

  • 为Turtlebot3添加控制器
  • 引入ROS桥接器和ROS OmniGraph(OG)节点
  • 设置机器人以接收ROS2 Twist消息并驱动它

7.2.2.2. Getting Started

重要提示:

在运行Isaac Sim之前,请确保从终端中启动您的ROS 2安装。如果在您的bashrc中包含了ROS 2的sourcing步骤,则可以直接运行Isaac Sim。

7.2.2.3. Main Concepts

在URDF导入:Turtlebot结束时,机器人具有可驱动的关节,当给定目标位置或速度时,它可以移动关节以匹配目标。然而,在大多数情况下,您希望控制车辆速度而不是单个车轮速度。因此,我们首先要添加适当的控制器。对于Turtlebot3这样的双轮机器人,所需的节点是差动控制器和关节控制器。差动控制器节点将车辆速度转换为轮速,而关节控制器节点则向关节驱动发送命令。

有关如何连接这些节点的详细说明可以在OmniGraph中找到类似机器人(NVIDIA Jetbot)的详细说明,因此我们在这里不会详细介绍。

作为我们ROS2桥接器的一部分,我们提供了订阅者和发布者特定消息的节点,一些实用节点,如跟踪模拟时间和上下文ID。您还会找到“辅助节点”,这些节点是通往更复杂的Omnigraphs的门户,我们将它们从用户那里抽象出来。

要为特定主题建立ROS2桥接器,步骤可以概括为以下几点:

  1. 打开一个操作图
  2. 添加与所需rostopics相关的OG节点
  3. 根据需要修改任何属性
  4. 连接数据管道

ROS2发布者节点是将Omniverse Isaac Sim数据打包成ROS消息并发送到ROS网络的地方,而订阅者节点是接收ROS2消息并将其分配给相应的Omniverse Isaac Sim参数的地方。因此,要使用它们,我们只需根据每个节点的属性将必要的数据导入和导出。如果您需要发布或订阅超出我们提供的消息,请查看Omnigraph: Custom Python Nodes或Omnigraph: Custom C++ Nodes,以了解如何集成自定义Omnigraph节点的方法。

7.2.2.4. Putting it Together

7.2.2.4.1. Building the Graph

以下是操作指南:

  1. 打开可视化脚本:窗口 > 可视化脚本 > 操作图。操作图窗口将出现在底部,您可以将其停靠在任何方便的位置。

  2. 单击操作图窗口中间的新操作图图标。

  3. 在操作图窗口内,左侧有一个面板,显示了所有Omnigraph节点(或OG节点)。所有与ROS2相关的OG节点都列在Isaac Ros2下。您还可以通过名称搜索节点。要将节点放入图中,只需从节点列表中将其拖动到图窗口中。如果所有与ROS相关的节点都标记为Ros1而不是Ros2,则意味着您已启用了ROS桥接器而不是ROS2。转到窗口 > 扩展,以禁用ROS桥接器并启用ROS2桥接器。

  4. 创建一个与下面图形匹配的图形。
    image
    image

7.2.2.4.2. Graph Explained
  • 在“播放时”生成一个时钟信号的“On Playback Tick Node”。接收来自该节点的时钟信号的节点将在每个模拟步骤中执行其计算功能。

  • “ROS2 Context Node”:ROS2使用DDS作为其中间件通信。DDS使用域ID允许不同的逻辑网络独立操作,即使它们共享一个物理网络。在相同域中的ROS 2节点可以自由发现彼此并发送消息,而在不同域中的ROS 2节点则不能。ROS2上下文节点使用给定的域ID创建上下文。默认情况下设置为0。如果选中“Use Domain ID Env Var”,它将从启动当前Isaac Sim实例的环境中导入ROS_DOMAIN_ID。

  • “ROS2 Subscribe Twist Node”:订阅Twist消息。在其属性选项卡中的“topicName”字段中指定ROS主题的名称/cmd_vel。

  • 注意,订阅者节点通常有一个“Exec Out”字段。这类似于一个时钟信号,当订阅者节点接收到消息时会发送一个信号。在这种情况下,我们只希望在接收到新的扭转消息时计算差分命令。因此,差分节点的“Exec In”由订阅者节点的输出而不是“On Playback Tick”打勾。

  • “Scale To/From Stage Unit Node”:将资产或输入转换为舞台单位。

  • “Break 3-Vector Node”:Twist订阅节点的输出是线速度和角速度,都是三维矢量。但是,差分控制器节点的输入仅接受前进速度和z轴旋转速度,因此在将它们输入到差分控制器节点之前,我们需要分解数组并提取相应的元素。

  • “Differential Controller Node”:此节点接收期望的车辆速度并计算机器人的轮速。它需要车轮半径和轮距来进行计算。它还可以接收可选的速度限制参数以限制轮速。在属性选项卡中输入车轮半径、轮距和车辆的最大线速度,以匹配Turtlebot。

  • “Articulation Controller Node”:此节点分配给目标机器人,然后接受需要移动的关节的名称或索引,并通过给定的位置、速度或力的命令移动它们。

  • 注意,“Articulation Controller node”由“On Playback Tick”打勾。因此,如果没有新的Twist消息到达,它将继续执行之前接收到的任何命令。

  • 要将“Articulation Controller node”的目标分配为Turtlebot。在属性选项卡中,取消选择“Use Path”,并单击“Target”以查找弹出框中的Turtlebot prim。确保您选择的机器人prim也是应用Articulation Root API的地方。有时它是机器人的父级prim。但通常对于移动机器人来说,它是底盘prim。如果您使用了我们之前的教程进行URDF导入器,则可以在turtlebot3_burger/base_footprint上找到Articulation Root API。有关Articulation API的更多信息,请参阅添加Articulation。

  • 要以数组格式放置车轮关节的名称,请在每个“Constant Token node”中输入车轮关节的名称,并将名称数组馈送到“Make Array Node”。Turtlebot的关节名称为wheel_left_joint和wheel_right_joint。

  • 如果您想知道为什么不将名称放在“Constant String node”中,那是因为Omnigraph没有字符串数组数据类型,因此如果需要将字符串放在数组格式中以供节点使用,则需要使用令牌类型。

7.2.2.4.3. Verifying ROS connections
  • 按下“播放”按钮开始执行图形和物理模拟。

  • 在另一个已经启动了ROS的终端中,使用ros2 topic list命令检查相关的rostopics是否存在。除了/rosout/parameter_events外,应该还列出了/cmd_vel

  • 现在设置了差分基本主题,可以向/cmd_vel主题发布扭转消息来控制机器人。让我们使用以下命令将其向前驱动:

    ros2 topic pub /cmd_vel geometry_msgs/Twist '{linear: {x: 0.2, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.0}}'
    
  • 为了让我们更容易地移动Turtlebot,请通过以下命令安装teleop_twist_keyboard

    sudo apt-get install ros-$ROS_DISTRO-teleop-twist-keyboard
    
  • 运行以下命令启用使用键盘进行驾驶:

    ros2 run teleop_twist_keyboard teleop_twist_keyboard
    
posted on 2024-02-26 21:08  FrostyForest  阅读(141)  评论(0编辑  收藏  举报