KPN++
A C++20 Kahn Process Network (KPN) library. Each node wraps a function and runs in its own thread, communicating with downstream nodes via bounded FIFO channels. Includes Python bindings via nanobind.
Requirements
| Dependency | Version | Notes |
|---|---|---|
| CMake | ≥ 3.21 | |
| C++ compiler | GCC ≥ 11, Clang ≥ 13, MSVC 19.29 | C++20 required |
| Threads | system | find_package(Threads) |
| nanobind | ≥ 2.1 | auto-fetched if not installed; Python ≥ 3.8 |
| Catch2 | v3 | auto-fetched for tests |
| Google Test | v1.14 | auto-fetched for tests |
| OpenCV | ≥ 4 | optional; only for example 09 |
Build
cmake -B build -DKPN_BUILD_PYTHON=OFF # core + tests + C++ examples
cmake --build build --parallel
ctest --test-dir build
Enable Python bindings (requires nanobind and Python dev headers):
cmake -B build -DKPN_BUILD_PYTHON=ON
cmake --build build --parallel
Disable examples:
cmake -B build -DKPN_BUILD_EXAMPLES=OFF
Core Concepts
Nodes
A node wraps any callable. Its input types are taken from the function's parameter list; its output types from the return type. Multi-output nodes return std::tuple<...>.
#include <kpn/kpn.hpp>
using namespace kpn;
// Single input, single output
int double_it(int x) { return x * 2; }
// Multi-output — must return std::tuple
std::tuple<cv::Mat, cv::Mat> split(cv::Mat frame) { ... }
// Sink — void return, no output ports
void display(cv::Mat frame) { cv::imshow("out", frame); }
Creating Nodes
// No port names (index-only access)
auto node = make_node<double_it>(/*fifo_capacity=*/5);
// Named input ports only
auto node = make_node<double_it>(in<"value">{}, 5);
// Named input and output ports
auto node = make_node<double_it>(in<"value">{}, out<"result">{}, 5);
// Named output ports only (e.g. a source with no inputs)
auto node = make_node<capture>(out<"colour","grey">{}, 5);
Port names are NTTP fixed_string values — resolved entirely at compile time, zero runtime cost.
Building a Network
Network is non-owning — declare nodes first, then register them. Nodes must outlive the network.
auto src = make_node<produce>(in<"x">{}, out<"value">{}, 5);
auto proc = make_node<double_it>(in<"value">{}, out<"result">{}, 5);
Network net;
net.add("src", src)
.add("proc", proc)
.connect("src", src.output<0>(), "proc", proc.input<0>()) // by index
.connect("src", src.template output<"value">(), "proc", proc.template input<"value">()) // by name
.build(); // runs cycle detection — throws NetworkCycleError on cycles
net.start();
// ... do work ...
net.stop();
Named port syntax in template context: when the node variable is
auto-deduced, use.template output<"name">()and.template input<"name">()to help the parser.
Channel Semantics
- Bounded FIFO: default capacity 5, configurable per-node at construction.
- Blocking
pop(): consumer blocks until data is available (KPN semantics). - Throwing
push(): throwsChannelOverflowErrorif the channel is full and accepting. - Silent drop on disabled channel: after
node.stop(), its input channels are disabled — producers that push into them have the value silently dropped. No exception, no blocking. - Source throttling: source nodes (no inputs) must sleep or yield to avoid overflowing downstream FIFOs. See example 09.
Storage Policy
Large types (sizeof > 8 or non-trivially-copyable) are stored as std::shared_ptr<const T> inside the channel — no copies, shared immutable ownership. Small trivially-copyable types are stored by value.
Override the policy for a specific type:
template<> struct kpn::channel_storage_policy<MyType> {
static constexpr bool by_value = true;
};
Shutdown
node.stop() / net.stop():
- Sets
accepting_ = falseon all input channels (drops in-flight pushes silently). - Clears any queued items from those channels.
- Unblocks any thread blocked on
pop()(throwsChannelClosedErrorinsiderun_loop, which exits cleanly). - Joins the node thread.
Named Ports — Design Notes
Port names use C++20 NTTP fixed_string. The deduction guide is required:
template<std::size_t N>
fixed_string(const char (&)[N]) -> fixed_string<N>;
fixed_string<4> and fixed_string<7> are distinct types — input<"img">() and input<"sigma">() resolve to different template instantiations at compile time. Wrong names produce a static_assert at the call site with a readable message.
Sub-Networks
Network implements INode, so it can be nested inside a larger Network:
// Inner sub-network
Network pipe;
pipe.add("pre", pre_node)
.add("enh", enh_node)
.connect("pre", pre_node.output<0>(), "enh", enh_node.input<0>())
.expose_input("img", pre_node.input<0>())
.expose_output("result", enh_node.output<0>())
.build();
// Outer network
Network top;
top.add("pipe", pipe)
.add("sink", sink_node)
.connect("pipe", pipe.output<"result">(), "sink", sink_node.input<0>())
.build();
top.start();
Display / GUI Nodes
Do not wrap imshow/waitKey as a KPN node. Qt and Wayland require these to run on the main thread (the thread that owns the event loop). Instead, wire the final output channel to a Channel<cv::Mat> and pop it on the main thread:
Channel<cv::Mat> result_ch(8);
comp.set_output_channel<0>(&result_ch);
// ... build and start network ...
// Main thread display loop
while (true) {
cv::Mat frame;
if (!result_ch.try_pop(frame, std::chrono::milliseconds(100))) continue;
cv::imshow("output", frame);
if (cv::waitKey(1) == 'q') break;
}
net.stop();
Python Bindings
Python bindings are scaffolded but not yet fully implemented. See
python/kpn_python.cppandinclude/kpn/python/bindings.hpp.
A PyNetwork is constructed from a closed list of C++ node types. The variant of all port types is derived at compile time — no runtime type registration needed.
GIL rules (non-negotiable):
- Acquire the GIL only for the duration of a Python callable invocation.
- Release the GIL before any blocking channel operation (
pop(),push(),net.read(),net.write()).
Violating the second rule deadlocks.
Examples
| Example | What it shows |
|---|---|
01_hello_pipeline |
Linear pipeline, index-based port wiring |
02_named_ports |
in<>/out<> name tags, named port access |
03_multi_output |
Tuple-returning node, per-element sub-port routing |
04_storage_policy |
channel_storage_policy default and specialisation |
05_error_handling |
ChannelOverflowError, ErrorHandler |
06_watchdog |
Watchdog interval, stall detection |
07_python_network |
PyNetwork, pure Python node (pending) |
08_python_subport |
net.read, net.write, sub-port tap (pending) |
09_opencv_cellshade |
Real-time cell-shading on webcam/pattern; requires OpenCV ≥ 4 |
Run the cell-shading example:
./build/examples/09_opencv_cellshade
# Press 'q' or close the window to stop.
# Falls back to an animated synthetic pattern if no webcam is found.
Project Structure
include/kpn/
fixed_string.hpp — NTTP string, in<>/out<> tags, index_of
traits.hpp — function_traits, normalised_return_t, output_count_v
channel.hpp — Channel<T>, channel_storage_policy, exceptions
port.hpp — InputPort<N,I>, OutputPort<N,I>
node.hpp — Node<Func,in<...>,out<...>>, make_node, INode
network.hpp — Network (builder, cycle detection, watchdog)
variant_node.hpp — VariantNode, PythonConverter<T>, unique_types (Python layer)
python/
bindings.hpp — nanobind helpers, GIL rule documentation
kpn.hpp — umbrella header
src/
network.cpp — non-template Network implementation
tests/
test_fixed_string.cpp
test_traits.cpp
test_channel.cpp
test_node.cpp
test_network.cpp
python/
kpn_python.cpp — nanobind module entry point
examples/
01_hello_pipeline/ … 09_opencv_cellshade/