前言
如今机器人脱控状态下的行为决策智能越来越被强调。首先对什么是机器人行为决策进行简单解释:机器人行为决策是指机器人在特定环境下通过感知和推理,从众多行为选项中选择出最佳行为策略,并产生相应的动作执行,从而实现自主行动和解决问题的能力。机器人行为决策系统设计有以下原则:
- 适应性:机器人行为决策系统需要具备适应多变环境和任务需求的能力,需要能够根据环境变化自动调整策略和行为。
- 高效性:行为决策系统需要具备高效的决策速度。要在有限的时间内作出最佳的决策,并能够高效地执行决策结果。
- 可靠性:主要体现在该行为决策系统的鲁棒性。
4. 透明性:行为决策系统需要具备一定的透明性和可解释性,主要是增强用户对其理解。
实现机器人行为决策的算法途径有很多,包括决策树、行为树、状态机等,思想相似。最终决定通过行为树来实现。本文基于对以下参考文章与参考教程的学习做出解释和梳理,希望能为更多需要学习行为树的人提供参考和帮助。
参考教程:BehaviorTree.CPP
一、行为树与决策树的区别
下面选取决策树和行为树这两种不同的算法途径,比较两者的区别。
二者最主要的区别在于遍历方式,且布局方式和节点类型也不一样。
决策树:为了制定决策。决策树每一次都从根到叶子分析。为使决策树正确工作,子节点需要表达出父节点所有可能的决策。在到达结束节点前,任何一个底层节点都可以被遍历到。这种遍历总是向下的。
行为树:为了控制行为。行为树有不同的分析方法。第一次分析(或重置后)从根节点开始 ,从左到右逐个分析子节点(子节点将按照优先级排列)。当一个子节点满足所有条件时就开始其对应的行为,该节点状态即为“运行中”(异步行为中),并返回这个行为。下一次分析这棵树的时候,重新从最高优先级的节点(根节点)开始检查,同样以从左到右的顺序遍历节点,当运行到状态为“运行中”的节点时就会继续执行被暂停的行为。当任意条件失败时遍历器返回该行为节点的父节点,父选择器接着移动到下一个优先级的子节点上。
行为树的运行框架比决策树更复杂。行为树允许执行更复杂的行为,适合用于更多对行为的控制。考虑到实际目标是令机器人通过自主决策执行不同的行为,更侧重于行为部署。因此学习行为树更为合适。
行为树与决策树动画演示参考:决策树vs行为树_行为树和决策树区别-CSDN博客
二、在ros中搭建行为树框架(基础部分)
1、行为树关键属性概括
1)节点:节点表示的是任务(行为)与条件,而非状态。
2)状态:一项任务或行为一般是一段循环运行一次或多次的代码,并产生一个SUCCESS或FAILURE的结果。如果一项任务需要通过多于一次的循环来完成,它将会在返回结果之前保持一个RUNNING的状态。一项任务的当前状态总是会传递给行为树中的父任务。(用tick()信号在树中传播)
3)复合行为:任务或行为还可以表示复合行为,其状态取决于两个或多个子行为。最常用的两个复合行为是选择器和序列器。下面两条将较为详细地说明两个复合行为。
4)选择器:一个选择器首先尝试执行其第一个子行为,且若它执行成功,则该选择器执行成功(即其中一个成功就判定选择器成功)。否则选择器会尝试执行第二个行为,以此类推。若所有子行为都执行失败,则该选择器执行失败。
5)序列器:一个序列器尝试着依次运行它的所有子任务(有C++风格,相当于一次性编译),如果任何一个子任务执行失败,则判定该序列器执行失败。如果所有子行为都运行成功,则该序列器执行成功。相当于C++中的编译规则。
6)当一个给定的节点被运行,其结果(SUCCESS、FAILURE或RUNNING)会被传递给它的父节点。同2)
7)修饰器:行为可通过修饰器进行扩展升级,修饰器可以修改行为的结果。
8)“黑板”:许多行为树利用一个全局的“黑板”,存储关于环境的数据以及先前行为的结果。独立的节点可以对黑板进行读和写的操作。
9)关于BehaviorTreeFactory:在行为树设计中,通常会将行为树的构建和执行分离开来。构建行为树需要定义树的结构、节点和它们的连接关系,这通常是通过特定的语法或配置来完成。BehaviorTreeFactory的作用就是在行为树的构建阶段提供方法和工具来创建各种行为树节点,设置它们的属性并连接它们。它可以根据预定义的节点类型、条件和任务来创建具体的节点实例,并提供方法来将这些节点组成一个完整的行为树。使用BehaviorTreeFactory可以使行为树的定义更加灵活、模块化和可复用。通过将行为树的构建过程抽象到工厂中,可以在运行时动态地创建、修改和组合节点,从而实现更复杂的行为逻辑和更容易维护的行为树结构。
2、xml文件重点解析
xml文件中详细具体的标签介绍及功能解释参考官网教程。下面只将xml架构中的重点进行解析。
1)<root>根标签中,关于[main_tree_to_execute]属性,如果文件包含多个<BehaviorTree>,则该属性为必填,否则为可选。
2)事实上,<root>根标签不一定需要[BTCPP_format]或[main_tree_to_execute]标签。
3)输入输出端口可以使用blackboard,用BB键的形式指向blackboard条目。BB键的具体语法格式为{key name}。如下面的示例:
<SaySomething message="{my_message}"/>
运用了BB键,将该子项读取并写入黑板,写入的条目的键即为“my_message”。
4)blackboard也可以理解为一棵树,是所有节点共享的简单键/值存储树。其“条目”是键/值对。输入端口可以读取blackboard中的条目,而输出端口可以写入条目。
5)输入端口的使用
<SaySomething name="first" message="hello world" />
<SaySomething name="second" message="{greetings}" />
这里“message”是输入端口。第一个节点是直接读取字符串的形式,即一个名为“first”的端口直接接收字符串“hello world”;而第二个节点使用了BB键,被要求在blackboard中查找值,即一个名为“second”的节点用greeting键查找blackboard中相应的条目。
3、字符串解析
StringView的C++11版本是std::string_view。可以传递std::string或const char。
该库提供了一个简单的splitString功能,可用于分隔字符串。如:
auto parts = splitString(str, ';');
4、异步操作
异步操作用于一个需要很长时间才能完成的节点,当未达到完成标准时其节点状态会返回RUNNING。
一般用StatefulActionNode实现异步操作。
实现StatefulActionNode必须重写(override)以下虚拟方法,而不是重写tick()信号
(tick的作用:一般tick信号会被发送到树的根部,在树中传播,直到达到根节点。接收到tick信号的树节点执行其callback回调函数,此回调必须返回SUCCESS,FAILURE or RUNNING):
NodeStatus onStart():节点处于空闲状态时调用
NodeStatus onRunning():当节点处于RUNNING状态时调用。返回新状态。
void onHalted():当此节点被中止时调用。
5、override书写
虚拟方法的实例化放在类中完成,如下:
BT::NodeStatus onStart() override;
BT::NodeStatus onRunning() override;
void onHalted() override;
而虚拟方法的具体编写放在类外完成。
6、序列与反应序列
序列标签:<Sequence>
反应序列:<ReactiveSequence>
两者的区别在于序列按照顺序执行所有子节点,都成功才成功,有一个失败则失败。反应序列是有中断能力的序列节点,可以在任何一个子节点返回失败时终止执行。
7、使用子树撰写行为
“子树”的编写实现了更小且可重用的行为。由于子树的介入,行为树才有了分层且可组合的属性。通常在XML中定义多个树并使用<SubTree>标签将一棵子树包含在另一棵子树中。子树编写及插入示例如下:
<root BTCPP_format="4">
<BehaviorTree ID="MainTree">
<Sequence>
<Fallback>
<Inverter>
<IsDoorClosed/>
</Inverter>
<SubTree ID="DoorClosed"/>
</Fallback>
<PassThroughDoor/>
</Sequence>
</BehaviorTree>
<BehaviorTree ID="DoorClosed">
<Fallback>
<OpenDoor/>
<RetryUntilSuccessful num_attempts="5">
<PickLock/>
</RetryUntilSuccessful>
<SmashDoor/>
</Fallback>
</BehaviorTree>
</root>
8、重映射子树的端口
从父树的角度来看,子树节点相当于一个独立节点。为了在结构复杂、分支较多的树中避免名称冲突,任何树及子树都需要用不同的Blackboard实例,因此才需要重映射子树端口。即要将父树的端口显式连接到其子树。
子树的重映射不需要修改cpp文件,只需在XML文件中完成。
例:
<BehaviorTree ID="MainTree">
<Sequence>
<Script code=" move_goal='1;2;3' " />
<SubTree ID="MoveRobot" target="{move_goal}"
result="{move_result}" />
<SaySomething message="{move_result}"/>
</Sequence>
</BehaviorTree>
在主树MainTree中包含子树MoveRobot,重映射操作则是将子树MoveRobot中的端口(target与result)与主树中的其他端口连接起来了(这里是将target与move_goal连接,将result与move_result;连接)。
9、使用多个xml文件
法一:手动加载多个文件(推荐)
假设maintree.xml文件中包含两个子树SubtreeA与SubtreeB,主树XML文件书写格式如下:
<root>
<BehaviorTree ID="MainTree">
<Sequence>
<SaySomething message="starting MainTree" />
<SubTree ID="SubTreeA" />
<SubTree ID="SubTreeB" />
</Sequence>
</BehaviorTree>
<root>
同时要修改cpp文件的主函数,手动加载多个文件:
创建主树节点,子树节点也会同时自动被创建。如下:
// You can create the MainTree and the subtrees will be added automatically.
std::cout << "----- MainTree tick ----" << std::endl;
auto main_tree = factory.createTree("MainTree");
main_tree.tickWhileRunning();
法二:使用“include”
同样假设主树中包含子树SubtreeA和SubtreeB,则可以修改主树的xml文件如下,实现使移动树本身包含在主树xml文件本身中:
<root BTCPP_format="4">
<include path="./subtree_A.xml" />
<include path="./subtree_B.xml" />
<BehaviorTree ID="MainTree">
<Sequence>
<SaySomething message="starting MainTree" />
<SubTree ID="SubTreeA" />
<SubTree ID="SubTreeB" />
</Sequence>
</BehaviorTree>
<root>
include语句中,path所包含的文件相对路径表示当前文件目录下的子树xml文件。BehaviorTreeFactory就会在该相对路径下查找所需的依赖项。
用这种方法则不需要在cpp文件中修改加载语句,使用一般创建树的方法即可:
factory.createTreeFromFile("main_tree.xml")
10、将其他参数传递给节点
在创建节点类时都需要提供传递了以下参数的构造函数:
MyCustomNode(const std::string& name, const NodeConfig& config);
而某些情况下我们需要将其他参数、指针、引用等传递给类的构造函数。
传递其他参数不要使用端口或blackboard!!
传参方法如下。
法一:向构造函数添加参数(推荐)
所传递的额外参数不限于内置类型,可以是任意复杂的对象。向构造函数传递参数实例如下:
// additional arguments passed to the constructor
Action_A(const std::string& name, const NodeConfig& config,
int arg_int, std::string arg_str):
SyncActionNode(name, config),
_arg1(arg_int),
_arg2(arg_str) {}
主函数中注册此节点并传递已知参数:
BT::BehaviorTreeFactory factory;
factory.registerNodeType<Action_A>("Action_A", 42, "hello world");
法二:使用“初始化”方法
假设在Action_B中加入其他参数,同时要写构造函数中的传参和初始化函数。如下:
// The constructor looks as usual.
Action_B(const std::string& name, const NodeConfig& config):
SyncActionNode(name, config) {}
// We want this method to be called ONCE and BEFORE the first tick()
void initialize(int arg_int, const std::string& arg_str)
{
_arg1 = arg_int;
_arg2 = arg_str;
}
而该方法中注册和初始化Action_B的方法是不同的:
BT::BehaviorTreeFactory factory;
// Register as usual, but we still need to initialize
factory.registerNodeType<Action_B>("Action_B");
// Create the whole tree. Instances of Action_B are not initialized yet
auto tree = factory.createTreeFromText(xml_text);
// visitor will initialize the instances of Action_B
auto visitor = [](TreeNode* node)
{
if (auto action_B_node = dynamic_cast<Action_B*>(node))
{
action_B_node->initialize(69, "interesting_value");
}
};
// Apply the visitor to ALL the nodes of the tree
tree.applyVisitor(visitor);
11、脚本示例
XML中的脚本语言:实现的脚本语言允许用户快速读取/写入blackborad的变量。
脚本实例1:
param_A := 42
param_B = 3.14
message = 'hello world'
注意:运算符“:=”和“=”的区别在于如果该条目不存在于黑板中,前者可能会在黑板上创建一个新条目;而后者则会抛出一个例外。
脚本实例2:用分号添加多个单脚本中的命令
A:= 42; B:=24
算术运算符和括号:
param_A := 7
param_B := 5
param_B *= 2
param_C := (param_A * 3) + param_B
支持以下运算符:
算子 | 分配运算符 | 描述 |
+ | += | 加 |
- | -= | 减去 |
* | *= | 乘 |
/ | /= | 分 |
注意:操作字符串只能用加法运算符,实现两个字符串拼接。
逻辑和比较运算符:
val_A := true
val_B := 5 > 3
val_C := (val_A == val_B)
val_D := (val_A && val_B) || !val_C
注意: 逻辑比较运算符的返回值是布尔类型。
三元运算符:即问号表达式,实例如下
val_B = (val_A > 1) ? 42 : 24
xml文件中脚本语言的书写:
<Script code=" msg:='hello world' " />
<Script code=" A:=THE_ANSWER; B:=3.14; color:=RED " />
cpp文件中注册节点和枚举:
enum Color { RED=1, BLUE=2, GREEN=3 };
// We can add these enums to the scripting language
factory.registerScriptingEnums<Color>();
// Or we can do it manually
factory.registerScriptingEnum("THE_ANSWER", 42);
12、记录者和观察者
观察者模式下的记录器界面:
每当一个树节点改变其状态时,记录器都会调用一次回调函数,这种方式是一种非侵入式实现,即观察者模式。所调用的回调函数为:
virtual void callback(
BT::Duration timestamp, // When the transition happened
const TreeNode& node, // the node that changed its status
NodeStatus prev_status, // the previous status
NodeStatus status); // the new status
简单记录器实现:TreeObserver类
TreeObserver类用于收集树中的每个节点的以下统计信息:
struct NodeStatistics
{
// Last valid result, either SUCCESS or FAILURE
NodeStatus last_result;
// Last status. Can be any status, including IDLE or SKIPPED
NodeStatus current_status;
// count status transitions, excluding transition to IDLE
unsigned transitions_count;
// count number of transitions to SUCCESS
unsigned success_count;
// count number of transitions to FAILURE
unsigned failure_count;
// count number of transitions to SKIPPED
unsigned skip_count;
// timestamp of the last transition
Duration last_timestamp;
};
唯一标识节点:
由于观察者允许收集特定节点,因此需要一种方法来唯一标识该节点。可以使用两种机制:
TreeNode::UID():对应的唯一数字到树的深度优先遍历(DFS)
TreeNode::fullPath():成为一个独特的但人类可读的特定节点的标识符。
13、连接到groot
groot安装:
新建工作空间
下载groot 放到src文件下
(下载地址:https://github.com/BehaviorTree/Groot)
下载BehaviorTree.CPP3.8并替换进depend文件夹
(下载地址:https://github.com/BehaviorTree/BehaviorTree.CPP/tree/v3.8)
catkin_make
source ./devel/setup.bash
rosrun groot Groot
三、在ros中搭建行为树框架(高级部分/端口)
1、默认端口值
1)默认输入端口
设置默认值有两种等效写法:
BT::InputPort<Point2D>("pointB", "3,4", "...");
// should be equivalent to:
BT::InputPort<Point2D>("pointB", Point2D{3, 4}, "...");
默认黑板条目:
端口的默认值可以通过BB键指向默认黑板条目:
BT::InputPort<Point2D>("pointC", "{point}", "...");
如果端口名称与黑板条目名称相同,有以下两种等效写法:
BT::InputPort<Point2D>("pointD", "{=}", "...");
// equivalent to:
BT::InputPort<Point2D>("pointD", "{pointD}", "...");
注意:这里的“...”是一个占位符,该位置的功能是为端口提供默认值,实际操作时应该替换为合适的默认值。该默认值的类型的是多样的,例如一个字符串、一个数值、一个布尔值等,也可以是一个Point2D类型的值。Point2D是二维点类型,通常由两个坐标值组成,分别表示点的x,y坐标。Point2D类型数据可以用一个元组来表示,如(3,5)
2)默认输出端口
输出端口默认值设置受到更多限制,只能通过BB键指向黑板条目。当然,当端口名称与黑板条目名称相同时仍然可以用{=}。举例如下:
static PortsList providedPorts(){
return {
BT::OutputPort<Point2D>("result", "{target}", "point by default to BB entry {target}");
};
}
2、通过引用访问端口
通过引用访问端口又称对Blackboard的零拷贝访问。
一般Blackboard使用值语义,即用方法getInput和setOutput将值读取写入Blackboard,整个过程是复制过程,不共享地址。
而某些情况下最好改用引用语义,即访问对象直接存储在Blackboard中,Blackboard中的数据与原数据共享同一个地址,相当于给同一个数据不同的名称。
使用引用语义的条件:
复杂的数据结构
复制成本高昂(如Pointcloud)
不可复制
使用引用语义的方法有以下两种(以下选择复制成本高昂的点云Pointcloud对象为例):
方法一:将黑板条目作为共享指针
假设有一个如下简单的主树:
<root BTCPP_format="4" >
<BehaviorTree ID="SegmentCup">
<Sequence>
<AcquirePointCloud cloud="{pointcloud}"/>
<SegmentObject obj_name="cup" cloud="{pointcloud}" obj_pose="{pose}"/>
</Sequence>
</BehaviorTree>
</root>
其中行为AcquirePointCloud将写入黑板条目pointcloud,行为SegmentObject则将从该条目读取。
端口引用举例:
PortsList AcquirePointCloud::providedPorts()
{
return { OutputPort<std::shared_ptr<Pointcloud>>("cloud") };
}
PortsList SegmentObject::providedPorts()
{
return { InputPort<std::string>("obj_name"),
InputPort<std::shared_ptr<Pointcloud>>("cloud"),
OutputPort<Pose3D>("obj_pose") };
}
以上示例使用智能指针shared_ptr访问PointCloud实例。
方法二:线程安全castPtr
该方法从4.5.1版本开始推荐,而为与c++14版本对应,我们使用BT3.8版本。因此暂时不考虑该方法。
3、子树模型和自动重映射
1)子树模型
当在多个位置使用同一个子树时,会发现目前需要多次复制粘贴相同的xml长标签。长标签举例如下:
<SubTree ID="MoveRobot" target="{move_goal}" frame="world" result="{error_code}" />
除非子树的三个属性不同,否则多次的复制粘贴是在做无用功。为了避免这种情况,可以在<TreeNodesModel>中定义子树属性的默认值。如下:
<TreeNodesModel>
<SubTree ID="MoveRobot">
<input_port name="target" default="{move_goal}"/>
<input_port name="frame" default="world"/>
<output_port name="result" default="{error_code}"/>
</SubTree>
</TreeNodesModel>
设置默认值后,若在xml标签中重新设置(重映射)属性的值,则原默认值会被覆盖,未提及的属性保持默认值。
<SubTree ID="MoveRobot" frame="map" />
以上示例中,属性frame的值被重映射为map,而target和result的值则保持默认值。
2)自动重映射:
在多次引用子树标签,且我们所需要的属性值与默认值完全相同时,可以使用自动重映射,即属性_autoremap。这样就避免了多次复制粘贴相同长标签的问题。如上面的<SubTree>标签在经过<TreeNodesModel>设置后,在父树中引用时可替换为:
<SubTree ID="MoveRobot" _autoremap="true" />
其中‘_autoremap=“true”’可以自动重映射所有子树默认属性。当然我们仍然可以覆盖特定值,同时自动重映射其他值。示例如下:
<SubTree ID="MoveRobot" _autoremap="true" frame="world" />
属性frame的值被修改,其他属性的值仍然映射默认值。
总结
以上就是基于官网教程的行为树学习记录,希望能对更多需要学习行为树的人有所帮助。另外,官网中所出现的指南和节点库没有做专门解析,部分内容在文中有提及。若要进行专门查看,可参考官方地址。