pcl_kit — cheat sheet for a coding agent¶
You are writing Python that drives the Point Cloud Library (PCL) — a C++ point
cloud library — through pcl_kit. The kit mirrors PCL's C++ API:
bringup_pcl() returns the real pcl namespace and you use pcl.PointCloud,
pcl.VoxelGrid, setInputCloud, setLeafSize, filter exactly as in the PCL
tutorials. The kit only removes the cppyy friction (bringup, NumPy<->cloud copies,
the ROS message bridge). You do not need to know cppyy.
(For why this exists and the C++-vs-Python comparison, see WHY.md.)
Requires the pcl pixi env: pixi run -e pcl python your_script.py.
Golden rules
- Call pcl = pcl_kit.bringup_pcl() once; it returns the pcl namespace.
Pass with_ros=False if you only need NumPy (skips the ~1.9 s pcl_conversions
JIT). Bringup is idempotent.
- Instantiate templates with subscript: pcl.PointCloud[pcl.PointXYZ],
pcl.VoxelGrid[pcl.PointXYZ] — any point type works on demand
(pcl.PointXYZI, pcl.PointXYZRGB, pcl.PointNormal, ...).
- A filter needs a shared pointer as input: vox.setInputCloud(cloud.makeShared()).
- Move bulk data with the kit's bridges, never a Python per-point loop.
- Keep the cloud referenced while you use a copy=False NumPy view of it.
Pattern 1 — NumPy cloud -> VoxelGrid -> NumPy (the minimal path)¶
Use for: filtering/processing a cloud you have as a NumPy (N,3) float array.
import numpy as np
import pcl_kit
pcl = pcl_kit.bringup_pcl(with_ros=False)
points = np.random.rand(100_000, 3).astype(np.float32) # (N,3) or (N,4)
cloud = pcl_kit.cloud_from_numpy(points) # ONE C++ memcpy
vox = pcl.VoxelGrid[pcl.PointXYZ]() # PCL's own API
vox.setInputCloud(cloud.makeShared())
vox.setLeafSize(0.05, 0.05, 0.05)
out = pcl.PointCloud[pcl.PointXYZ]()
vox.filter(out)
down = pcl_kit.cloud_to_numpy(out) # (M,3) float32, safe copy
print(cloud.size(), "->", out.size())
cloud_from_numpy accepts (N,3) (strided copy) or (N,4) (single memcpy; the
4th column is the padding lane). cloud_to_numpy(out, copy=False) returns a
near-free zero-copy view instead — but it aliases the cloud's storage, so keep
out alive while you use the view.
Pattern 2 — ROS PointCloud2 pipeline, cloud stays in C++ (the money path)¶
Use for: a ROS 2 node that filters sensor_msgs/PointCloud2 without ever
materializing points in Python. Subscribe via rclcpp_kit so the callback gets the
C++ message.
import os; os.environ.setdefault("ROS_DOMAIN_ID", "43")
import rclcpp_kit
import pcl_kit
rclcpp = rclcpp_kit.bringup_rclcpp()
pcl = pcl_kit.bringup_pcl() # with_ros=True (default)
from sensor_msgs.msg import PointCloud2
node = rclcpp.Node("cloud_filter")
out_pub = node.create_publisher(PointCloud2, "points_out", 10)
def on_cloud(msg): # msg is a C++ PointCloud2
cloud = pcl_kit.cloud_from_msg(msg) # pcl::fromROSMsg, no Python per-point
vox = pcl.VoxelGrid[pcl.PointXYZ]()
vox.setInputCloud(cloud.makeShared())
vox.setLeafSize(0.05, 0.05, 0.05)
out = pcl.PointCloud[pcl.PointXYZ]()
vox.filter(out)
out_pub.publish(pcl_kit.msg_from_cloud(out)) # pcl::toROSMsg
sub = node.create_subscription(PointCloud2, "points_in", on_cloud, 10)
executor = rclcpp.executors.SingleThreadedExecutor()
executor.add_node(node)
executor.spin() # or spin_some() in a loop
cloud_from_msg(msg, point_type=pcl.PointXYZI) instantiates a different point type
on demand. msg_from_cloud(cloud, msg=existing) fills an existing message in place.
See scripts/pcl_kit_demos/d02_ros_pipeline.py for the full self-contained showcase.
Pattern 3 — a point type no binding ever shipped (on-demand templates)¶
Use for: stock PCL point types beyond XYZ. Just subscript with the type; the ROS
bridge takes a point_type argument.
pcl = pcl_kit.bringup_pcl()
cloud = pcl.PointCloud[pcl.PointXYZINormal]() # instantiated on demand
# ... or straight off a ROS message with intensity:
cloud_i = pcl_kit.cloud_from_msg(msg, point_type=pcl.PointXYZI)
Pattern 4 — a fully custom point type (cppdef)¶
Use for: a struct with your own fields (e.g. a LiDAR point with ring). Two
rules the REPORT nailed down: use struct alignas(16) (not the trailing
} EIGEN_ALIGN16; macro — Cling rejects it), and include the template impl
headers so the filter instantiates for your type.
import cppyy
import pcl_kit
pcl = pcl_kit.bringup_pcl(with_ros=False)
cppyy.cppdef(r"""
#include <pcl/point_types.h>
#include <pcl/point_cloud.h>
#include <pcl/impl/pcl_base.hpp> // needed for PCLBase<T>
#include <pcl/filters/voxel_grid.h>
#include <pcl/filters/impl/voxel_grid.hpp> // needed for VoxelGrid<T>
struct alignas(16) MyLidarPoint { // alignas PREFIX, not EIGEN_ALIGN16
PCL_ADD_POINT4D; // x, y, z (+ padding)
float intensity;
std::uint16_t ring;
};
POINT_CLOUD_REGISTER_POINT_STRUCT(MyLidarPoint,
(float, x, x)(float, y, y)(float, z, z)
(float, intensity, intensity)(std::uint16_t, ring, ring))
""")
MyPoint = cppyy.gbl.MyLidarPoint
cloud = pcl.PointCloud[MyPoint]()
vox = pcl.VoxelGrid[MyPoint]() # works over the custom type
Pattern 5 — compile-cached VoxelGrid, and warmup for the rest¶
Use for: any node/loop whose first frame must not be a latency outlier. The
dominant first-frame cost is cppyy JIT-instantiating PCL's VoxelGrid<PointXYZ>
(~0.6 s). pcl_kit.voxel_downsample(cloud, leaf) runs a VoxelGrid compiled once
into the kit's .so (cppdef_cached), so its first use is ~5 ms and persistent —
prefer it over building pcl.VoxelGrid[...] by hand in a hot path. The showcase
frame-0 drops ~681 ms → ~88 ms. First run on a machine pays a one-time ~3 s .so
build; no compiler → it falls back to the Python VoxelGrid path (pcl_kit._CACHED).
import numpy as np, pcl_kit
pcl_kit.bringup_pcl() # caches the glue + voxel_downsample here
cloud = pcl_kit.cloud_from_numpy(pts)
out = pcl_kit.voxel_downsample(cloud, 0.05) # compiled VoxelGrid; ~5 ms first use
warmup(with_ros=True) still front-loads what the cache doesn't yet cover — the
pcl_conversions toROSMsg/fromROSMsg round-trip (the same cacheable pattern, a
compiled conversion helper, is the next step). Pass with_ros=False for the
NumPy-only path. See docs/kits/COMMON_PATTERNS.md §23 (cache) and FREEZE.md §4.
Gotchas (short version)¶
- Don't convert clouds with a Python per-point loop — it is ~90x slower than
the kit's C++ memcpy and building the aligned storage from Python can segfault
the process. Use
cloud_from_numpy/cloud_to_numpy. - Don't spell a custom point struct with
} EIGEN_ALIGN16;— Cling parse-errors and a failedcppdefcan crash on transaction revert. Usestruct alignas(16). - For a filter over a novel point type, include its
impl/*.hpp(e.g.pcl/filters/impl/voxel_grid.hpp) or you get unresolved-symbol errors. VoxelGrid's is pre-included by the kit. - A filter's input is a shared pointer:
setInputCloud(cloud.makeShared()). cloud_to_numpy(cloud, copy=False)aliases PCL memory — keep the cloud alive.- Use
bringup_pcl(with_ros=False)for NumPy-only work to skip the ROS JIT; the ROS bridges (cloud_from_msg/msg_from_cloud) requirewith_ros=True.