36 KiB
KPN++ — Kahn Process Network Library Specification
Overview
A C++20 template-metaprogramming library for building Kahn Process Networks, where each node wraps a function/method, runs in its own thread, and communicates via bounded FIFO queues. Includes nanobind bindings for Python graph construction and prototyping.
Project Structure
kpn++/
├── CMakeLists.txt
├── include/kpn/
│ ├── fixed_string.hpp # NTTP string type for named ports
│ ├── traits.hpp # Function signature introspection
│ ├── channel.hpp # Bounded FIFO channel + storage policy
│ ├── node.hpp # Node wrapper + thread management
│ ├── port.hpp # Input/Output port handles
│ ├── network.hpp # Graph builder + orchestrator/watchdog
│ ├── variant_node.hpp # Runtime-typed node for Python graphs
│ └── python/
│ └── bindings.hpp # Nanobind binding helpers
├── src/
│ └── network.cpp # Orchestrator thread impl
├── tests/
├── examples/
│ ├── 01_hello_pipeline/
│ ├── 02_named_ports/
│ ├── 03_multi_output/
│ ├── 04_storage_policy/
│ ├── 05_error_handling/
│ ├── 06_watchdog/
│ ├── 07_python_network/
│ ├── 08_python_subport/
│ └── 09_opencv_cellshade/ # optional, requires OpenCV
└── python/
└── kpn_python.cpp # Nanobind module definition
Component 0 — fixed_string.hpp: NTTP String
Named ports use C++20 non-type template parameters (NTTPs). std::string_view and
const char* are not valid NTTPs because they are not structurally comparable. The standard
solution is a fixed_string literal type with constexpr internal storage.
template<std::size_t N>
struct fixed_string {
char data[N]{};
constexpr fixed_string(const char (&s)[N]) { std::copy_n(s, N, data); }
constexpr bool operator==(const fixed_string&) const = default;
constexpr std::string_view view() const { return {data, N - 1}; }
};
// Deduction guide — required so fixed_string("img") works as an NTTP.
// Without it the compiler cannot infer N and the named-port API does not compile.
template<std::size_t N>
fixed_string(const char (&)[N]) -> fixed_string<N>;
fixed_string<3> and fixed_string<6> are distinct types, so input<"img">() and
input<"sigma">() produce different template instantiations — this is intentional and
enables zero-overhead compile-time port dispatch.
Named-port lookup uses a constexpr function over the name pack. It returns a sentinel
npos on miss rather than static_assert-ing internally, so the assertion fires at the
input<"img">() call site — giving the user a readable error at the point of use instead
of deep in template instantiation:
inline constexpr std::size_t npos = std::size_t(-1);
template<fixed_string Name, fixed_string... Names>
constexpr std::size_t index_of() {
std::size_t i = 0;
bool found = false;
((Name == Names ? (found = true) : (found ? 0 : ++i)), ...);
return found ? i : npos;
}
// Used at the call site:
template<fixed_string N>
auto input() {
constexpr std::size_t idx = index_of<N, InputNames...>();
static_assert(idx != npos, "unknown input port name");
return input<idx>();
}
Component 1 — traits.hpp: Function Introspection
Extracts parameter types and return type from any callable at compile time.
// For: Image blur(Image in, float sigma)
// function_traits<decltype(blur)>::args == std::tuple<Image, float>
// function_traits<decltype(blur)>::return_t == Image
// For multi-output: std::tuple<Image, Mask> detect(Image in)
// return_t == std::tuple<Image, Mask> → 2 output ports
// return_t == Image → 1 output port (normalised to tuple<Image> internally)
// return_t == void → 0 output ports (sink node)
Handles: free functions, lambdas, std::function, member function pointers.
A helper alias normalises the return type to always be a tuple for uniform handling in
run_loop:
template<typename T>
using normalised_return_t =
std::conditional_t<is_tuple_v<T>, T, std::tuple<T>>;
// void return → std::tuple<> (empty tuple, zero output ports)
Component 2 — channel.hpp: Bounded FIFO + Storage Policy
Storage Policy
The type stored in a channel depends on a specialisable trait. Users can override it for any type:
template<typename T>
struct channel_storage_policy {
static constexpr bool by_value =
std::is_trivially_copyable_v<T> && sizeof(T) <= 8;
};
// User opt-in to value semantics for a small struct:
template<> struct channel_storage_policy<MySmallStruct> {
static constexpr bool by_value = true;
};
// Derived storage type:
template<typename T>
using channel_storage_t = std::conditional_t<
channel_storage_policy<T>::by_value,
T,
std::shared_ptr<const T>
>;
Channel
Channel<T> stores channel_storage_t<T> internally. The producer calls push(T value)
and the channel transparently wraps it in make_shared<const T> when needed. All consumers
of the same channel receive the same shared_ptr — no copies of large objects.
run_loop dereferences shared_ptr<const T> before passing to the wrapped function, so a
function declared void f(const Image& img) works naturally and the compiler enforces
immutability — no policy enforcement or const_cast needed.
template<typename T>
class Channel {
public:
using storage_type = channel_storage_t<T>;
explicit Channel(std::size_t capacity = 5);
void push(T value); // wraps in shared_ptr<const T> if needed; throws on overflow
T pop(); // blocks (KPN semantics); unwraps shared_ptr if needed
bool try_pop(T& out, std::chrono::milliseconds timeout);
std::size_t size() const;
std::size_t capacity() const;
};
class ChannelOverflowError : public std::runtime_error {};
Ownership
A Channel<T> is owned by its consumer node — it lives as a member of the destination
node. The producer node holds a non-owning raw pointer to push into it. The channel is
destroyed when its consumer is destroyed, which is the correct lifetime.
The Network itself is non-owning — nodes are declared by the user and outlive the
network. net.add("name", node) registers a raw pointer; the user is responsible for keeping
nodes alive for the network's lifetime. This avoids type-erasure ownership complexity and
keeps node construction explicit.
Backpressure and Shutdown — accepting_ Flag
Each channel carries a single std::atomic<bool> accepting_ (default true). This is the
sole shutdown mechanism — no try_pop polling, no sentinel values, no drain logic.
template<typename T>
class Channel {
std::atomic<bool> accepting_{true};
public:
void push(T value) {
if (!accepting_.load(std::memory_order_relaxed)) return; // silently drop
// normal push — throws ChannelOverflowError if full
}
void enable() { accepting_.store(true, std::memory_order_relaxed); }
void disable() {
accepting_.store(false, std::memory_order_relaxed);
clear(); // drop all queued items immediately
cv_.notify_all(); // unblock any waiting pop()
}
};
Who flips the flag: the consumer node — it's the channel owner. node.stop() calls
disable() on all its input channels. node.start() calls enable(). The producer never
touches the flag; it calls push() and if the channel is disabled the value is silently
dropped and the producer continues.
Overflow (push() on a full, accepting channel) still throws ChannelOverflowError —
this signals a design error (undersized FIFO) and is unchanged.
Blocking pop() unblocks immediately when disable() is called (via cv_.notify_all()),
and throws ChannelClosedError if the queue is empty and the channel is disabled.
try_pop Purpose
try_pop exists for watchdog polling only — not for shutdown (the accepting_ flag
handles that) and not for normal processing. The watchdog uses it to probe whether a node
is making progress without blocking the watchdog thread.
Note (C++ compile-time graphs): In a fully compiled C++ graph the variant never appears. The compiler wires
Channel<Image>toChannel<Image>directly. The variant is a pure compile-time construct used only for type-checking and generates zero runtime overhead.
Component 3 — port.hpp: Port Handles
template<typename NodeT, std::size_t Idx>
struct InputPort { NodeT& node; };
template<typename NodeT, std::size_t Idx>
struct OutputPort { NodeT& node; };
Nodes expose port handles via:
// By index — always available
node_a.input<0>() // returns InputPort<NodeA, 0>
node_b.output<1>() // returns OutputPort<NodeB, 1>
// By name — only valid when names were provided at make_node time
node_a.input<"img">()
node_b.output<"edges">()
Named access resolves to an index at compile time via index_of (see fixed_string.hpp).
Zero runtime cost — the name dispatch is fully eliminated by the compiler.
Component 4 — node.hpp: Node Wrapper
template<
auto Func,
fixed_string... InputNames, // optional; count must match arity or be 0
fixed_string... OutputNames // optional; count must match output count or be 0
>
class Node {
public:
explicit Node(std::size_t fifo_capacity = 5);
void start();
void stop(); // signals thread to finish current item then exit
// Port access — by index
template<std::size_t I> auto input();
template<std::size_t I> auto output();
// Port access — by name (compile error if names were not provided)
template<fixed_string N> auto input();
template<fixed_string N> auto output();
static constexpr std::size_t input_count;
static constexpr std::size_t output_count;
private:
void run_loop();
// Pops each input channel, dereferences shared_ptr if needed,
// calls Func, unpacks the normalised tuple return,
// pushes each element to its output channel.
std::thread thread_;
// Input channels owned here (one per input port).
// Output channel pointers (non-owning) set at connect time.
};
Factory syntax — in<> / out<> tag structs:
A flat name pack make_node<f, "a", "b", "c"> is ambiguous (where do inputs end?).
Option chosen: in<...> and out<...> tag types that wrap the name packs unambiguously.
Both are optional; omitting either means those ports are index-only.
// Tag types (trivial, no data):
template<fixed_string... Names> struct in {};
template<fixed_string... Names> struct out {};
// Factory:
// No names
auto node = make_node<my_func>(/*fifo_capacity=*/10);
// Input names only
auto node = make_node<my_func, in<"img","sigma">>(10);
// Both input and output names
auto node = make_node<my_func, in<"img","sigma">, out<"blurred","mask">>(10);
Wrong name count is a compile error. The Node class static_asserts that
sizeof...(InputNames) == 0 || sizeof...(InputNames) == input_count (and same for outputs).
Without this, a mismatch between name count and arity produces an unreadable template error.
static_assert(
sizeof...(InputNames) == 0 || sizeof...(InputNames) == input_count,
"make_node: number of input names must match function arity, or provide none"
);
Multi-output functions must return std::tuple<...>. Single return accepted as-is.
void return = sink node (no output ports).
Component 5 — network.hpp: Graph Builder + Orchestrator
Network is non-owning — nodes are declared by the user and must outlive the network.
add() registers a raw pointer. Graph construction uses a builder pattern so the full
topology is known before build(), enabling cycle detection and topological ordering.
class Network : public INode { // Network is itself an INode — enables sub-networks
public:
// Register a node by name. NodeT must satisfy INode. Network holds a raw pointer.
template<typename NodeT>
Network& add(std::string name, NodeT& node);
// Connect output port of src to input port of dst.
// Type mismatch → static_assert at compile time.
template<typename SrcNode, std::size_t SrcIdx,
typename DstNode, std::size_t DstIdx>
Network& connect(const std::string& src_name, OutputPort<SrcNode, SrcIdx>,
const std::string& dst_name, InputPort<DstNode, DstIdx>);
// Expose an internal node's input/output as a boundary port of this (sub-)network.
// Allows a Network to be connected into a larger Network like a single node.
template<typename NodeT, std::size_t Idx>
Network& expose_input(std::string boundary_name, InputPort<NodeT, Idx>);
template<typename NodeT, std::size_t Idx>
Network& expose_output(std::string boundary_name, OutputPort<NodeT, Idx>);
// DFS cycle check + topological sort. Throws NetworkCycleError on cycles.
Network& build();
void start() override; // starts all internal nodes in topological order
void stop() override; // stops all internal nodes in reverse order; disables channels
bool running() const override;
void set_watchdog_interval(std::chrono::milliseconds);
using ErrorHandler = std::function<void(std::string_view node_name, std::exception_ptr)>;
void set_error_handler(ErrorHandler);
private:
std::map<std::string, INode*> nodes_; // non-owning
std::map<std::string, std::vector<std::string>> adj_;
std::vector<std::string> topo_;
std::jthread watchdog_;
std::chrono::milliseconds watchdog_interval_{500};
ErrorHandler error_handler_;
};
Node lifetime contract: nodes must outlive the Network. The typical pattern is to
declare nodes and the network in the same scope:
// Nodes declared first — they own their input channels
auto blur = make_node<blur_func, in<"img","sigma">>(10);
auto detect = make_node<detect_func, in<"img">>(10);
Network net;
net.add("blur", blur)
.add("detect", detect)
.connect("blur", blur.output<0>(), "detect", detect.input<0>())
.connect("blur", blur.output<"blurred">(), "detect", detect.input<"img">())
.build();
net.start();
Sub-networks — because Network implements INode, it can be registered inside a
larger Network as a named node. Boundary ports declared via expose_input /
expose_output make the internal nodes' ports available to the outer graph:
// Inner sub-network
auto stage1 = make_node<preprocess>(5);
auto stage2 = make_node<enhance>(5);
Network pipe;
pipe.add("pre", stage1).add("enh", stage2)
.connect("pre", stage1.output<0>(), "enh", stage2.input<0>())
.expose_input("img", stage1.input<0>())
.expose_output("result", stage2.output<0>())
.build();
// Outer network treats `pipe` as a single node
auto sink = make_node<display>(5);
Network top;
top.add("pipe", pipe).add("sink", sink)
.connect("pipe", pipe.output<"result">(), "sink", sink.input<0>())
.build();
top.start();
NetworkCycleError is thrown by build() if the graph contains a directed cycle.
Component 6 — variant_node.hpp: Runtime-typed Node (Python graphs)
Motivation
Python graphs cannot use compile-time type resolution. A PyNetwork is constructed with a
closed list of C++ node types known at binding time. The library derives a deduplicated
std::variant from all port types across those nodes. Type safety is enforced at
connect() time via string signatures.
Variant Deduplication
All port types from the registered nodes are collected into a flat pack, duplicates are
removed via a unique_types TMP metafunction, then the variant is instantiated once:
template<typename... Nodes>
using py_variant_t = std::variant<unique_types_t<all_port_types_t<Nodes...>>>;
This is pure TMP and runs entirely at compile time. The resulting variant has no redundant alternatives at runtime.
PyNetwork Construction
make_py_network is a pure C++ template — no CMake code-gen step. The variant is
derived entirely at compile time from the registered node type list. The nanobind module
definition is the single place where node types are listed; recompiling the extension is
the "registration" step.
// In kpn_python.cpp — list all node types that may appear in Python graphs:
auto py_net = make_py_network<NodeA, NodeB, NodeC>();
// VariantValue = std::variant< /* deduplicated port types from A, B, C */ >
// Registers to_python / from_python converters for each alternative.
VariantChannel
using VariantValue = py_variant_t</* registered nodes */>;
class VariantChannel {
public:
explicit VariantChannel(std::size_t capacity = 5);
void push(VariantValue v); // throws ChannelOverflowError if full
VariantValue pop(); // blocks (KPN semantics)
};
VariantNode
Wraps a registered C++ node type. Its run_loop uses std::visit to extract the concrete
type from a VariantValue, calls the underlying function, then wraps the result back into a
VariantValue for the output channel.
class VariantNode {
public:
std::string input_type_sig(std::size_t idx) const;
std::string output_type_sig(std::size_t idx) const;
void connect_input (std::size_t port, std::shared_ptr<VariantChannel>);
void connect_output(std::size_t port, std::shared_ptr<VariantChannel>);
void start();
void stop();
};
PythonConverter — Crossing the C++/Python Boundary
Every type in the variant must provide a PythonConverter specialisation. This is the
single mechanism used for all data crossing into or out of Python (PyNodes, net.read,
net.write):
template<typename T>
struct PythonConverter {
static nanobind::object to_python(const T&);
static T from_python(nanobind::object);
};
PyNode — Pure Python Processing Node
A PyNode holds a nanobind::object as its function. Its run_loop:
- Pops
VariantValuefrom each input channel std::visit→ callsPythonConverter<T>::to_pythonfor each → acquires GIL- Calls the Python callable
- Releases GIL → calls
PythonConverter<R>::from_pythonon the return value - Pushes result as
VariantValueto output channel
Sub-value Extraction and Injection
A C++ node returning std::tuple<A, B, C> exposes three independent output ports. Each
element is pushed to its own VariantChannel — sub-indexing is a first-class concept at the
channel level, not an afterthought.
Python tap — read one output port into Python:
value = net.read("detect", output=2)
# Pops from output channel 2, calls PythonConverter<C>::to_python.
# GIL released while blocking on pop(), re-acquired before to_python call.
Python inject — write a Python value into a specific input port:
net.write("blur", input=1, value=my_sigma)
# Calls PythonConverter<float>::from_python(my_sigma), pushes to input channel 1.
# GIL released while blocking on push() if channel is full.
Python splitter node:
def split(packed):
img, mask, score = packed
return img, mask
net.add_node("split", split, inputs=["packed"], outputs=["img", "mask"])
net.connect("detect", 0, "split", 0)
net.connect("split", 0, "show", 0)
net.connect("split", 1, "save", 0)
Direct C++ sub-output to Python node input:
net.connect("detect", 1, "py_thresh", 0)
# Type sig of detect:output[1] must match py_thresh:input[0] — checked at connect().
Type-check at connect time (Python)
# Raises kpn.TypeError if signatures don't match
net.connect("blur", 0, "thresh", 0) # (src_name, out_idx, dst_name, in_idx)
Component 7 — Orchestrator / Watchdog
Runs in its own dedicated thread inside Network / PyNetwork. Responsibilities:
- Starts nodes in topological order; stops them in reverse order
- Tracks per-node execution time (exponential moving average)
- Emits warning via logger callback (default:
stderr) when a node stalls beyond threshold - Catches exceptions from node threads and routes them to
ErrorHandler - Graceful shutdown: signals all nodes, joins with timeout, reports any that fail to stop
GIL Rules (non-negotiable constraints on binding implementation)
Two rules govern all interaction between node threads and the Python interpreter:
-
Acquire for callback — a node thread must hold the GIL only for the duration of a Python callable invocation (
nb::gil_scoped_acquirewrapping the call site). -
Release while blocking — any blocking operation on a channel (
pop(),push(),net.read(),net.write()) must release the GIL before blocking (nb::gil_scoped_releasewrapping the call site), then re-acquire after.
Violating rule 2 deadlocks: a PyNode thread waiting to acquire the GIL cannot proceed while another thread holds the GIL and blocks on a channel waiting for that PyNode to produce.
Error Handling Contract
| Situation | Behaviour |
|---|---|
| FIFO overflow | ChannelOverflowError thrown in producer thread → ErrorHandler |
| Node function throws | Exception pointer captured → ErrorHandler |
| Type mismatch (C++) | static_assert at connect() compile time |
| Type mismatch (Python) | kpn.TypeError raised at net.connect() call |
| Cycle in graph | NetworkCycleError thrown at build() time |
| Thread fails to stop | Watchdog warning after configurable timeout |
from_python / to_python fails |
Exception propagated to ErrorHandler |
Future Extension Points (Heterogeneous Execution)
Not implemented now, but the design must not close these doors:
-
IChannelabstract interface —Channel<T>and a futureRemoteChannel<T>(wrapping a socket/queue) would share the samepush/popinterface. Nodes never know whether their channel is in-process or remote. -
Serializer<T>trait — parallel toPythonConverter<T>andchannel_storage_policy, a specialisable trait for cross-device serialisation (MessagePack for ESP32, pinned memory for GPU zero-copy, etc.). -
NodeKindtag —enum class NodeKind { Local, Gpu, Remote }on theINodeinterface, letting the watchdog apply different health-check and timeout strategies per device type.
These three extension points are sufficient to support GPU and embedded/network targets without redesigning the core.
Thread Model
v1: one std::thread per node. This maps directly to KPN theory and is simple to reason
about. It does not scale to networks with hundreds of nodes but is appropriate for the
typical use case (tens of nodes, each doing non-trivial work).
std::jthread (C++20) is preferred over std::thread where available, as it provides a
built-in stop_token that simplifies the stop() / try_pop shutdown pattern.
A future executor/thread-pool model (where multiple nodes share a pool of threads and are
scheduled cooperatively) is a possible v2 extension. The INode interface is designed to
not assume a 1:1 thread mapping.
Platform and Compiler Requirements
C++20 is required. Specific features used:
| Feature | Header / Standard | Min compiler |
|---|---|---|
NTTP structural types (fixed_string) |
language | GCC 11, Clang 13, MSVC 19.29 |
std::is_trivially_copyable_v |
<type_traits> |
C++17+ |
std::jthread + stop_token |
<thread> |
GCC 11, Clang 14, MSVC 19.29 |
if constexpr, fold expressions |
language | C++17+ |
auto NTTPs |
language | C++20 |
Concepts (requires) |
language | GCC 10, Clang 10 |
Minimum supported compilers: GCC 11, Clang 13, MSVC 19.29 (VS 2022). nanobind requires Python 3.8+ and a C++17-capable compiler (satisfied by the above).
Testing Strategy
Test frameworks: Catch2 v3 (header-friendly, good async/threading support via
REQUIRE_NOTHROW + thread join patterns) and Google Test (for death tests and
parameterised test suites). Both are included; use Catch2 for integration/behaviour tests
and GTest for unit tests where ASSERT_* / EXPECT_* macros and death tests are
preferable.
Hard cases to cover explicitly:
| Case | What to test |
|---|---|
| Channel blocking | pop() blocks until a producer pushes; unblocks exactly once per push |
| Channel overflow | push() beyond capacity throws ChannelOverflowError |
| Shutdown race | stop() called while a node is blocked on pop() — thread must exit cleanly |
| Multi-consumer | Two nodes connected to the same output channel each receive every item (fan-out) |
| Tuple unpacking | Multi-output node pushes correct type to each sub-channel |
| Cycle detection | build() throws NetworkCycleError for a graph with a cycle |
| Named port lookup | input<"wrong">() fires static_assert; input<"right">() resolves correctly |
| Wrong name count | make_node with mismatched name count fires readable static_assert |
| GIL deadlock | PyNode + blocking net.read() from Python do not deadlock |
from_python failure |
Exception propagates to ErrorHandler, network continues |
channel_storage_policy |
Large type is stored as shared_ptr<const T>; small type by value |
Examples
Each example is a self-contained program under examples/. They are built as part of the
CMake build and serve as both documentation and smoke tests.
examples/01_hello_pipeline — Basic linear pipeline
Two nodes connected in sequence. Demonstrates make_node, Network builder,
index-based port connection, start_all / stop_all.
// producer → transform → sink
int produce() { return 42; }
int double_it(int x) { return x * 2; }
void print_it(int x) { std::cout << x << '\n'; }
auto src = make_node<produce>(5);
auto dbl = make_node<double_it>(5);
auto sink = make_node<print_it>(5);
Network net;
net.add("src", src)
.add("dbl", dbl)
.add("sink", sink)
.connect("src", src.output<0>(), "dbl", dbl.input<0>())
.connect("dbl", dbl.output<0>(), "sink", sink.input<0>())
.build();
net.start_all();
examples/02_named_ports — Named port access
Same pipeline but using in<> / out<> name tags and named port access. Demonstrates
fixed_string NTTP dispatch and the static_assert on wrong names.
auto dbl = make_node<double_it, in<"value">, out<"result">>(5);
// ...
.connect("src", src.output<0>(), "dbl", dbl.input<"value">())
.connect("dbl", dbl.output<"result">(), "sink", sink.input<0>())
examples/03_multi_output — Tuple-returning node / sub-port routing
A single node returns std::tuple<Image, Mask>. Each output is routed to a different
downstream node. Demonstrates tuple normalisation, per-element channel push, and
output<1>() sub-indexing.
std::tuple<Image, Mask> detect(Image in) { ... }
void show_image(const Image& img) { ... }
void save_mask(const Mask& m) { ... }
// detect:output<0> → show_image, detect:output<1> → save_mask
examples/04_storage_policy — channel_storage_policy specialisation
Shows the default behaviour (large struct stored as shared_ptr<const T>, small int by
value) and a user specialisation that overrides the default for a custom type.
struct BigFrame { uint8_t pixels[1920*1080*3]; };
// stored as shared_ptr<const BigFrame> automatically
struct Tiny { float x, y; }; // 8 bytes — by value by default
template<> struct channel_storage_policy<Tiny> { static constexpr bool by_value = true; };
examples/05_error_handling — Overflow and node exceptions
Demonstrates ChannelOverflowError (producer faster than consumer, tiny FIFO), custom
ErrorHandler, and a node that throws mid-execution.
net.set_error_handler([](std::string_view name, std::exception_ptr ep) {
try { std::rethrow_exception(ep); }
catch (const std::exception& e) {
std::cerr << "[" << name << "] " << e.what() << '\n';
}
});
examples/06_watchdog — Orchestrator / watchdog
A node that artificially stalls. Shows watchdog warning emission, configurable interval, and graceful shutdown after a timeout.
net.set_watchdog_interval(std::chrono::milliseconds(200));
// stall_node sleeps for 2s per item — watchdog fires warning after 200ms
examples/07_python_network — PyNetwork with C++ and Python nodes
Python script that imports kpn, registers C++ node types via make_py_network, adds a
pure Python processing node, connects them, and runs the graph.
import kpn
net = kpn.make_network([kpn.BlurNode, kpn.DetectNode])
def py_filter(img):
return img[::2, ::2] # downsample in Python
net.add_node("blur", kpn.BlurNode, inputs=["img"])
net.add_node("downsample",py_filter, inputs=["img"], outputs=["img"])
net.add_node("detect", kpn.DetectNode, inputs=["img"])
net.connect("blur", 0, "downsample", 0)
net.connect("downsample", 0, "detect", 0)
net.start()
examples/09_opencv_cellshade — Real-time cell-shading with OpenCV (optional)
Captures live video from a system camera and applies a cell-shading effect entirely inside a KPN++ graph. Built only when OpenCV is found at CMake time; skipped silently otherwise.
Graph topology:
[capture] ──Mat──> [split] ──B──> [median_b] ──B──┐
├──G──> [median_g] ──G──┤
└──R──> [median_r] ──R──┴──> [merge] ──Mat──> [combine] ──> [display]
[capture] ──Mat──────────────────────> [detect_edges] ──mask──────────> [combine]
Effect steps:
split_channels—cv::splitinto three single-channelcv::Matplanes.median_b/g/r— independentcv::medianBlur(kernel=15)per channel; large kernel posterises colours into flat cartoon-like regions and runs in parallel across channels.merge_channels—cv::mergeback to BGR.detect_edges— greyscale,cv::Canny, thencv::dilateto produce thick outlines.combine— zeros out BGR pixels wherever the edge mask is non-zero → black outlines drawn over the flat-colour image.display—cv::imshow; ESC key signals shutdown viag_runningatomic.
Demonstrates: named ports, fan-out from a single node to two downstream paths, parallel
per-channel processing, multi-input combine node, and error handler driving graceful stop.
// Build only if OpenCV is present:
// cmake .. -DKPN_BUILD_EXAMPLES=ON
// ./09_opencv_cellshade [camera_index] # default: 0
examples/08_python_subport — Python sub-value tap and inject
Shows net.read("node", output=N) and net.write("node", input=N, value=v) from Python,
plus connecting a C++ tuple output sub-port directly to a Python node input.
# Tap only output<1> (Mask) of a C++ detect node into Python
net.connect("detect", 1, "py_thresh", 0)
val = net.read("detect", output=0) # blocks until Image is available
net.write("blur", input=1, value=1.5) # inject sigma
Component 8 — Web Debug UI (optional, compile-time toggle)
An optional in-process HTTP server that serves a live graph visualisation of the running network. Zero cost when disabled — no symbols compiled in, no headers pulled.
Toggle
// Before any kpn include — enables the web debug server
#define KPN_WEB_DEBUG 1
#include <kpn/kpn.hpp>
CMake projects that want it globally:
option(KPN_WEB_DEBUG "Enable KPN++ web debug UI" OFF)
if(KPN_WEB_DEBUG)
target_compile_definitions(my_app PRIVATE KPN_WEB_DEBUG=1)
# cpp-httplib is fetched automatically by CMake when this flag is ON
endif()
Implementation
include/kpn/web_debug.hpp — included by network.hpp only when KPN_WEB_DEBUG is defined.
Depends on cpp-httplib (single-header, no external process, no Python required).
Served on localhost:9090 by default (configurable via net.set_web_debug_port(uint16_t)).
When enabled, Network gains:
#ifdef KPN_WEB_DEBUG
void set_web_debug_port(uint16_t port); // default 9090
void start_web_debug(); // called internally by start()
void stop_web_debug(); // called internally by stop()
#endif
start() automatically calls start_web_debug() when KPN_WEB_DEBUG is defined.
Endpoints
| Endpoint | Method | Description |
|---|---|---|
/ |
GET | Serves the single-page HTML app (inline, no files needed) |
/api/snapshot |
GET | Returns a JSON snapshot of all node and channel stats |
The HTML page is embedded as a C++ string literal — no asset files to deploy.
JSON Snapshot Format
{
"nodes": [
{ "id": "src", "frames": 120, "ema_exec_ms": 33.2, "max_exec_ms": 45.1,
"blocked_ms": 0.1, "fps": 29.8 },
{ "id": "quant", "frames": 120, "ema_exec_ms": 4.1, ... }
],
"edges": [
{ "source": "src", "target": "quant", "label": "colour",
"fill_pct": 12.5, "peak_pct": 87.5, "capacity": 8, "current": 1,
"pushes": 120, "drops": 0, "overflows": 0 }
]
}
Node id comes from the name registered via net.add("name", node).
Edge label comes from the channel name registered via connect() (format: "src:N → dst:M").
Browser UI
The page polls /api/snapshot every 500 ms and renders a D3.js v7 force-directed
graph:
- Nodes — circles labelled with node name; colour encodes exec load:
- green (
ema_exec_ms< 10ms), yellow (10–50ms), orange (50–100ms), red (>100ms) - hover tooltip shows: frames, ema_exec_ms, max_exec_ms, blocked_ms, fps
- green (
- Edges — directed arrows labelled with the channel name and fill%; colour:
- green (fill < 50%), yellow (50–80%), red (≥80%) — matches the
<<<flag in the text report - hover tooltip shows: pushes, drops, overflows, capacity
- green (fill < 50%), yellow (50–80%), red (≥80%) — matches the
D3 is loaded from CDN (d3js.org). The entire UI is a single inline HTML string in
web_debug.hpp — no file serving, no build step for assets.
Thread model
start_web_debug() launches a std::jthread running httplib::Server::listen().
The server is stopped via httplib::Server::stop() called from stop_web_debug().
/api/snapshot calls collect_snapshots() (already thread-safe — reads atomics with
relaxed ordering) and serialises to JSON using a minimal hand-rolled serialiser
(no third-party JSON library required).
Example usage
#define KPN_WEB_DEBUG 1
#include <kpn/kpn.hpp>
// ... build network as normal ...
net.set_web_debug_port(9090); // optional, 9090 is the default
net.start();
// Open http://localhost:9090 in a browser
CMake Layout
| Target | Type | Notes |
|---|---|---|
kpn |
header-only interface library | C++20, no external deps |
kpn_python |
nanobind shared library | links kpn, requires Python 3.8+ |
kpn_tests |
executable | Catch2 v3 + Google Test |
kpn_examples |
executables (one per example) | built by default, off with -DKPN_EXAMPLES=OFF |
kpn_web_debug |
compile-time option | #define KPN_WEB_DEBUG 1; fetches cpp-httplib via CMake FetchContent |
Resolved Design Decisions
All major design questions are now closed:
| Question | Decision |
|---|---|
| Shutdown mechanism | accepting_ flag per channel; disable() clears queue and unblocks pop() |
| Overflow behaviour | ChannelOverflowError thrown on full accepting channel; silently dropped on disabled channel |
| Network ownership | Non-owning; user declares nodes, network holds raw pointers |
| Node lifetime contract | Nodes must outlive their Network; declare in same scope |
| Sub-networks | Network implements INode; expose_input/expose_output define boundary ports |
make_py_network |
Pure C++ template; nanobind module recompilation is the registration step |
| GIL strategy | Acquire per Python callback; release while blocking on channel ops |