ABS (Agile But Safe)
repository·main·Indexed 20 days ago
https://github.com/lecar-lab/absAn implementation of collision-free, high-speed legged locomotion using reinforcement learning. ABS integrates agile and recovery policies with a Reach-Avoid (RA) network for safety. It includes support for the Unitree Go1 robot, utilizing the unitree_legged_sdk for communication and NVIDIA Isaac Gym for simulation and training.
What's inside ABS
- pybind11 is a lightweight, header-only C++11 library designed to expose C++ types in Python and vice versa. It is primarily used to create Python bindings for existing C++ code with minimal boilerplate by using compile-time introspection to infer type information. Unlike Boost.Python, it is self-contained and does not require linking against large external libraries, as it relies on C++11 features like tuples, lambda functions, and variadic templates.
Robot compatibility for unitree_legged_sdk v3.8.6
mainThe
unitree_legged_sdkis used for communication between a PC and the Controller board, or between PCs via UDP.Supported Robots:
- Go1
Unsupported Robots:
- Laikago
- B1
- Aliengo
- A1
Note: For support for other robots, check the v3.3.1 release.
How to use opaque types to enable pass-by-reference for STL containers
mainBecause automatic STL conversion uses copying, passing a
std::vector<int>&to a function from Python will not allow the Python side to see modifications made by C++. To fix this, you can make the type opaque.An opaque type disables the template-based conversion machinery, meaning the contents are never inspected or extracted, allowing them to be passed by reference.
Steps to implement opaque types:
- Use the
PYBIND11_MAKE_OPAQUE(T)macro at the top level (outside any namespace) before any binding code. This must be present in every compilation unit using the type. - Provide a corresponding
py::class_<T>declaration to define the name and available operations (likeappend,pop_back, etc.) in Python.
// 1. Declare as opaque at the top level PYBIND11_MAKE_OPAQUE(std::vector<int>); // 2. Bind the class to define Python interface py::class_<std::vector<int>>(m, "IntVector") .def(py::init<>()) .def("pop_back", &std::vector<int>::pop_back) .def("__len__", [](const std::vector<int> &v) { return v.size(); });- Use the
Understand C++11 clock types and their Python mappings
mainWhen using chrono conversions, the resulting Python type depends on which C++ clock is used. There are three standard clock types:
std::chrono::system_clock: Measures current date and time. It is subject to OS updates (e.g., NTP synchronization).- Python Mapping: Converts to
datetime.datetime(naive, local timezone).
- Python Mapping: Converts to
std::chrono::steady_clock: Ticks at a constant rate and is never adjusted. Ideal for measuring intervals/timing, but does not represent wall-clock time.- Python Mapping: Converts to
datetime.timedelta(representing time since the clock's epoch).
- Python Mapping: Converts to
std::chrono::high_resolution_clock: The clock with the highest resolution available on the system.- Python Mapping: Behavior varies by system. If it is a typedef of
system_clock, it returnsdatetime.datetime. If it is a different clock, it returnsdatetime.timedelta.
- Python Mapping: Behavior varies by system. If it is a typedef of
Pass Eigen objects by reference using Eigen::Ref
mainTo avoid expensive copies and allow functions to modify NumPy arrays in-place, use
Eigen::Ref<MatrixType>(for mutable access) orEigen::Ref<const MatrixType>(for read-only access) in your C++ function signatures.Requirements for zero-copy mapping:
- Type Compatibility: The NumPy
dtypemust match the EigenScalartype (e.g.,float64fordouble). - Layout Compatibility: The storage must be compatible (see Storage Orders).
- Writeability: For non-const
Eigen::Ref<MatrixType>, the NumPy array must havea.flags.writeable == True.
If these requirements are not met, pybind11 will fall back to making a temporary copy.
Note: Passing by reference is not supported for sparse types; they are always copied.
// This function modifies the input NumPy array directly void scale_by_2(Eigen::Ref<Eigen::VectorXd> v) { v *= 2; }- Type Compatibility: The NumPy
Avoid double-free errors with shared pointers
mainWhen using
std::shared_ptras a holder type, avoid returning raw pointers from functions that return objects managed by smart pointers. Returning a raw pointer causes pybind11 to create a new, independentstd::shared_ptrthat claims ownership, leading to a double-free (segmentation fault) when both pointers attempt to deallocate the object.To resolve this, use one of two methods:
- Consistent Wrapping: Always return the smart pointer type itself instead of a raw pointer.
- Enable Shared From This: Inherit the managed class from
std::enable_shared_from_this<T>. This allows pybind11 to recognize and communicate with the existingstd::shared_ptr.
// Method 1: Return the shared_ptr directly std::shared_ptr<Child> get_child() { return child; } // Method 2: Use enable_shared_from_this class Child : public std::enable_shared_from_this<Child> { };Performance considerations for C++ and Python function roundtrips
mainPassing functions between C++ and Python involves instantiating wrapper code to translate invocations between the two languages. This introduces computational overhead.
Performance Risks:
- Frequent roundtrips (e.g., C++ $\rightarrow$ Python $\rightarrow$ C++ $\rightarrow$ Python) in a tight loop can significantly decrease performance due to accumulated wrapper overhead.
Optimization:
- pybind11 can optimize stateless functions (function pointers or lambdas without captured variables). If a stateless function is passed from Python to a C++ function exposed in Python, pybind11 extracts the underlying C++ function pointer to avoid the C++ $\rightarrow$ Python $\rightarrow$ C++ roundtrip.
Handle immutable type limitations with reference arguments
mainIn C++, functions often use mutable references (
int &i) or pointers (int *i) to modify arguments. While Python passes arguments by reference, basic Python types likestr,int,bool, andfloatare immutable.Binding a C++ function that modifies an immutable type will result in a Python function that appears to do nothing because the modification doesn't affect the original Python object.
Workarounds:
- Encapsulation: Wrap the immutable type in a custom C++ class that allows modifications.
- Lambda Wrapper: Bind a small lambda function that calls the C++ function and returns the modified values as a tuple.
// C++ function using a reference int foo(int &i) { i++; return 123; } // Binding using a lambda to return a tuple of (return_value, modified_arg) m.def("foo", [](int i) { int rv = foo(i); return std::make_tuple(rv, i); });Manage Python interpreter lifetime
mainThe Python interpreter's lifecycle is primarily managed by
py::scoped_interpreter.- RAII approach: Use
py::scoped_interpreter guard{};. The interpreter starts when the guard is created and shuts down when the guard is destroyed. Creating a second concurrentscoped_interpreterguard is a fatal error. - Manual approach: Use
py::initialize_interpreter()andpy::finalize_interpreter()to set the state directly.
Warning: Do not use raw CPython API functions
Py_InitializeandPy_Finalize, as they do not correctly handle pybind11's internal data. Also, be aware that while pybind11 modules can be re-initialized after a restart, third-party extension modules might not fully unload, potentially leading to memory leaks.- RAII approach: Use
Build C++ extensions with cppimport
mainThecppimportlibrary is a Python import hook that automatically detects C++ source files matching the requested module name. It compiles the C++ file usingpybind11and places the resulting extension in the same folder as the source, allowing for seamless loading.Manage the Global Interpreter Lock (GIL) in C++
mainWhen calling C++ functions from Python, the GIL is held by default. To allow long-running C++ code to run in parallel using multiple Python threads, you must explicitly release the GIL. Conversely, when a C++ function (like a trampoline for a virtual function) needs to call back into Python code, it must acquire the GIL.
Use
py::gil_scoped_releaseto release the lock andpy::gil_scoped_acquireto acquire it. Alternatively, for simple function bindings, you can use thepy::call_guard<py::gil_scoped_release>()policy.// Manual management in a wrapper m.def("call_go", [](Animal *animal) -> std::string { py::gil_scoped_release release; return call_go(animal); }); // Using call_guard policy (preferred for simple bindings) m.def("call_go", &call_go, py::call_guard<py::gil_scoped_release>()); // Acquiring GIL in a trampoline function std::string go(int n_times) { py::gil_scoped_acquire acquire; PYBIND11_OVERRIDE_PURE(std::string, Animal, go, n_times); }Pass Eigen dense objects by value
mainWhen a bound function accepts ordinary Eigen dense objects (e.g.,
Eigen::MatrixXd), pybind11 accepts anynumpy.ndarraywith compatible dimensions.Behavior:
- pybind11 copies the values from the NumPy array into a temporary Eigen variable.
- For sparse matrices, it copies to/from
scipy.sparse.csr_matrixorscipy.sparse.csc_matrix. - Limitation: This involves an implicit copy, which is expensive for large matrices and prevents the function from modifying the original NumPy array.