Why pcl_kit — the Point Cloud Library from Python via cppyy¶
pcl_kit lets you drive PCL — the standard C++ point
cloud library — from Python: the real C++ code owns the cloud and runs the
filters, while Python only orchestrates and moves data in/out as NumPy arrays or
ROS 2 PointCloud2 messages. PCL has no maintained Python binding (the old
python-pcl shipped a fixed handful of point types and is abandoned), so this
capability does not otherwise exist — and because it is the same C++ library
reading the same headers, cppyy instantiates templates for any point type on
demand, including ones no binding ever shipped.
This doc explains what that gives you over the C++ workflow, and the two distinct ways to use it. For the API, see SKILL.md; for the feasibility evidence, copy accounting, gaps, and benchmarks, see REPORT.md.
Side by side: the official VoxelGrid tutorial, C++ vs Python¶
On the left, the official
"Downsampling a PointCloud using a VoxelGrid filter"
tutorial from pointclouds.org, verbatim — and its build system. On the right,
the complete runnable file this repo ships, scripts/pcl_kit_demos/d01_voxel_numpy.py.
The filtering code — VoxelGrid, setInputCloud, setLeafSize, filter — is
identical; the Python drops the file I/O for a NumPy bridge and drops the entire
build system.
C++ — voxel_grid.cpp + CMakeLists.txt (official tutorial)¶
#include <iostream>
#include <pcl/io/pcd_io.h>
#include <pcl/point_types.h>
#include <pcl/filters/voxel_grid.h>
int main ()
{
pcl::PCLPointCloud2::Ptr cloud (new pcl::PCLPointCloud2 ());
pcl::PCLPointCloud2::Ptr cloud_filtered (new pcl::PCLPointCloud2 ());
// Fill in the cloud data from a .pcd file on disk.
pcl::PCDReader reader;
reader.read ("table_scene_lms400.pcd", *cloud);
std::cerr << "PointCloud before filtering: "
<< cloud->width * cloud->height << " data points." << std::endl;
// Create the filtering object
pcl::VoxelGrid<pcl::PCLPointCloud2> sor;
sor.setInputCloud (cloud);
sor.setLeafSize (0.01f, 0.01f, 0.01f);
sor.filter (*cloud_filtered);
std::cerr << "PointCloud after filtering: "
<< cloud_filtered->width * cloud_filtered->height
<< " data points." << std::endl;
pcl::PCDWriter writer;
writer.write ("table_scene_lms400_downsampled.pcd", *cloud_filtered);
return (0);
}
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(voxel_grid)
find_package(PCL 1.2 REQUIRED)
include_directories(${PCL_INCLUDE_DIRS})
link_directories(${PCL_LIBRARY_DIRS})
add_definitions(${PCL_DEFINITIONS})
add_executable (voxel_grid voxel_grid.cpp)
target_link_libraries (voxel_grid ${PCL_LIBRARIES})
…and this does not run yet. You need the CMakeLists.txt above, a
cmake . && make (or colcon build) to configure/compile/link against a dozen
libpcl_* libraries, and the table_scene_lms400.pcd sample file on disk,
before you can execute the binary.
Python — d01_voxel_numpy.py (pcl_kit, shipped in this repo)¶
#!/usr/bin/env python
"""A NumPy point cloud goes into a PCL VoxelGrid and comes back out as NumPy."""
import numpy as np
import pcl_kit
pcl = pcl_kit.bringup_pcl(with_ros=False) # NumPy-only, skip the ROS JIT
points = np.random.default_rng(0).random((100_000, 3), dtype=np.float32)
cloud = pcl_kit.cloud_from_numpy(points) # one C++ memcpy into the cloud
vox = pcl.VoxelGrid[pcl.PointXYZ]() # PCL's own API, verbatim
vox.setInputCloud(cloud.makeShared())
vox.setLeafSize(0.05, 0.05, 0.05)
downsampled = pcl.PointCloud[pcl.PointXYZ]()
vox.filter(downsampled)
out = pcl_kit.cloud_to_numpy(downsampled) # strided copy back to (M,3)
print(f"input: {points.shape[0]} points")
print(f"output: {out.shape[0]} points after 0.05 m VoxelGrid")
Run it directly: pixi run -e pcl demo-pcl-voxel. Prints
input: 100000 points / output: 8000 points after 0.05 m VoxelGrid.
What we gain (right here, from the comparison above)¶
- No compile step, no CMake. The C++ program needs a
CMakeLists.txt,find_package(PCL), and a build that links a dozenlibpcl_*libraries before it can run; the Python file runs the instant you invoke it. The only startup cost is a one-time ~1.3 s cppyy bringup (JIT-including the headers + loading the.soset), and only what you touch is JIT-compiled. - No wrapper, no codegen, no fixed type list. Nothing is generated.
VoxelGrid,setInputCloud,setLeafSize,filterare PCL's own names — the Python reads like the C++. And because cppyy instantiates templates from the headers on demand,pcl.VoxelGrid[pcl.PointXYZINormal]works even though no binding ever shipped that specialization (see REPORT.md section 1). - NumPy in / NumPy out at C++ speed. The bridge is a single
std::memcpy(~0.5 ms for 100k points), not a Python loop (~46 ms).pcl::PointXYZis a 16-byte aligned struct, so true zero-copy in is impossible — but "one memcpy in C++" is the honest floor, and out you can even take a zero-copy view (see the copy-accounting table in REPORT.md section 3). - Same library, full ecosystem. It is the same
libpcl_*.so, so every PCL algorithm (filters, KdTree, segmentation, registration, features) is reachable the moment youcppyy.includeits header — no per-feature binding work.
What the C++ version buys that this one doesn't. A compiled binary pays no JIT at startup and gets full static type-checking; the tutorial also reads/writes PCD files directly (pcl_kit does not surface PCD I/O yet — the demos bridge from NumPy/ROS instead). See REPORT.md section 5 for the full gap list.
Two ways to use it¶
Mode A — use PCL from Python (NumPy in / NumPy out)¶
Prototype point-cloud processing with NumPy arrays crossing into real PCL
algorithms, at Python speed of iteration. This is the capability that simply
doesn't exist otherwise: the maintained C++ PCL, driven entirely from a Python
script, over any point type. d01_voxel_numpy.py is this mode — good for
experimenting with filters, tuning parameters, and testing, where edit-run cycles
in seconds matter.
Mode B — a ROS 2 pipeline where the cloud never leaves C++¶
The money path. A leaf subscribes to a sensor_msgs/PointCloud2 as a C++
message (via rclcppyy), hands it straight to pcl::fromROSMsg, runs a PCL filter,
and republishes via pcl::toROSMsg — Python never touches a point.
scripts/pcl_kit_demos/d02_ros_pipeline.py is the showcase: a self-contained
synthetic 100k-point publisher at 10 Hz plus a subscribe -> VoxelGrid -> republish
pipeline, all in one process, all data in C++ end to end.
This is the repo's thesis for perception: a C++ point-cloud stack, orchestrated
from Python, consuming and producing real ROS messages, with no wrapper generated
and no build step. The honest comparison, d03_baseline_rclpy.py, writes the
same pipeline the way you would without the kit (deserialize to NumPy, voxel-
downsample in NumPy, re-serialize). bench_pcl_pipeline.py runs both:
| Variant | avg latency | CPU% @10 Hz | user LOC |
|---|---|---|---|
| pcl_kit (C++ end-to-end) | 3.8 ms | 6.9 % | 76 |
| rclpy + NumPy baseline | 56.5 ms | 60.5 % | 77 |
~15x lower latency and ~9x less CPU, in essentially the same number of lines. The point isn't that C++ is faster than NumPy (it is); it's that with pcl_kit you get the C++ speed without writing more code and without leaving Python to do it. (Shared-machine numbers — provisional; see REPORT.md section 4.)
Advantages of the cppyy approach¶
Grounded in the spike's measured numbers (see REPORT.md sections 3-4):
- No code generation, no wrapper build.
python x.pyis the whole workflow; contrast the tutorial'sfind_package(PCL)+cmake && make. Bringup is a one-time ~1.3 s JIT (~3.3 s if you also pull in the ROS message headers). - Header-following, so it tracks the installed version. cppyy reads PCL's own headers at runtime, so pcl_kit matches whatever PCL is installed (1.15 here) — no hand-maintained binding to drift out of sync.
- On-demand template instantiation over any point type.
PointCloud<T>/VoxelGrid<T>are instantiated the moment you use them, for stock and customT— the fixed-type-list limitation of old bindings is gone. - The cloud stays in C++ across the ROS boundary. Straight off an rclcppyy
subscription, the message is a C++
PointCloud2;fromROSMsg/toROSMsgkeep every point in C++. Python pays a language boundary only where you put a Python line — never per point. - A prototype-to-native lowering path. As with bt_kit, this is the L0 rung: prototype with cppyy JIT today; later freeze to a precompiled dictionary (no JIT at startup); later lower hot paths to compiled C++ — same PCL calls, same structure. (L1/L2 are the research direction — see PLAN.md.)
Limits¶
pcl_kit is a v0 spike. The NumPy bridge is PointXYZ-only (other fields round-trip
through ROS messages or your own cppdef), custom point types need a small C++
cppdef block (with alignas(16), not the trailing EIGEN_ALIGN16 macro — Cling
rejects it), only VoxelGrid's template-impl header is pre-included, and a few raw
cppyy operations (Python per-point loops, building aligned storage from Python)
are slow or segfault — which is exactly why the kit keeps cppyy behind a curated
surface. The full, honest list is in REPORT.md section 5.