From 9ce581b5cecac227f8a284281db2e2bbd03583f4 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 9 May 2026 08:36:51 +0200 Subject: [PATCH] Fixed bug when generating identical fanouts --- examples/13_debug_cellshade/main.cpp | 288 +++++++++++++++++++++++++++ examples/CMakeLists.txt | 4 + include/kpn/fanout.hpp | 12 +- include/kpn/tmp/fanout_groups.hpp | 9 +- tests/test_static_network.cpp | 64 ++++++ 5 files changed, 366 insertions(+), 11 deletions(-) create mode 100644 examples/13_debug_cellshade/main.cpp diff --git a/examples/13_debug_cellshade/main.cpp b/examples/13_debug_cellshade/main.cpp new file mode 100644 index 0000000..814c85d --- /dev/null +++ b/examples/13_debug_cellshade/main.cpp @@ -0,0 +1,288 @@ +// Example 13 — Debug Cell-Shading Pipeline with Tiled Debug Canvas +// +// An improved cell-shading pipeline where every processing node performs +// exactly one OpenCV operation. A variadic DebugCanvas node tiles N +// cv::Mat inputs into a single debug window, making each pipeline stage +// visible side-by-side at runtime. +// +// Improvements over example 12: +// - Bilateral filter before quantisation (edge-preserving smoothing): +// flattens colour regions without softening object edges +// - Dilated edge mask for bolder black outlines +// - 6-level quantisation for richer tonal detail +// +// Topology (auto-fanouts inserted by make_network): +// +// [capture]──┬──> [bilateral]──> [quant]──┬──> [comp]──> [debug:0 result] +// │ └──> [debug:1 quantised] +// ├──> [to_gray]──> [blur]──> [canny]──┬──> [dilate]──> [comp] +// │ └──> [debug:2 edges] +// └──> [debug:3 original] +// +// make_network detects the 3-way fan from [capture], the 2-way fan from +// [quant], and the 2-way fan from [canny], inserting FanoutNode instances +// automatically. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// ── Synthetic source for environments without a webcam ─────────────────────── + +static cv::Mat make_gradient(int W, int H) { + cv::Mat xr(H, W, CV_8UC1), yg(H, W, CV_8UC1), b(H, W, CV_8UC1, cv::Scalar(128)); + for (int x = 0; x < W; ++x) xr.col(x).setTo(x * 255 / W); + for (int y = 0; y < H; ++y) yg.row(y).setTo(y * 255 / H); + cv::Mat channels[3] = {b, yg, xr}; + cv::Mat grad; + cv::merge(channels, 3, grad); + return grad; +} + +// ── Pipeline nodes — one cv:: call per function ─────────────────────────────── + +static cv::Mat capture() { + constexpr int W = 640, H = 480; + static cv::VideoCapture cap; + static bool opened = false; + if (!opened) { + opened = true; + cap.open(0, cv::CAP_V4L2); + if (cap.isOpened()) { + cap.set(cv::CAP_PROP_FRAME_WIDTH, W); + cap.set(cv::CAP_PROP_FRAME_HEIGHT, H); + } else { + std::cerr << "[capture] no webcam — using synthetic animated pattern\n"; + } + } + cv::Mat frame; + if (cap.isOpened()) { + auto t0 = std::chrono::steady_clock::now(); + cap >> frame; + auto elapsed = std::chrono::steady_clock::now() - t0; + if (elapsed < std::chrono::milliseconds(20)) + std::this_thread::sleep_for(std::chrono::milliseconds(33) - elapsed); + if (frame.empty()) frame = cv::Mat::zeros(H, W, CV_8UC3); + } else { + static int tick = 0; + static cv::Mat grad = make_gradient(W, H); + ++tick; + frame = grad.clone(); + int r = 150 + (tick % 80) * 4; + cv::circle(frame, {W/2, H/2}, r, {255, 200, 0}, -1); + cv::circle(frame, {W/2, H/2}, r / 2, { 0, 128, 255}, -1); + cv::circle(frame, {W*2/5, H*2/5}, r / 3, {200, 0, 200}, -1); + std::this_thread::sleep_for(std::chrono::milliseconds(33)); + } + return frame.clone(); +} + +// Edge-preserving smooth: flattens colour within regions while keeping sharp +// boundaries — much better than Gaussian blur as a pre-quantisation step. +static cv::Mat bilateral_filter(cv::Mat bgr) { + cv::Mat out; + cv::bilateralFilter(bgr, out, 5, 75, 75); + return out; +} + +// Snap each channel to N discrete tonal levels. +static cv::Mat quantise(cv::Mat bgr) { + constexpr int levels = 6; + constexpr double step = 256.0 / levels; + static const cv::Mat lut = []() { + cv::Mat l(1, 256, CV_8UC1); + for (int i = 0; i < 256; ++i) + l.at(i) = cv::saturate_cast( + std::floor(i / step) * step + step / 2.0); + return l; + }(); + cv::Mat out; + cv::LUT(bgr, lut, out); + return out; +} + +// BGR → greyscale; the edge path works on the original (not bilateral-filtered) +// frame so that fine edge detail is preserved. +static cv::Mat to_gray(cv::Mat bgr) { + cv::Mat gray; + cv::cvtColor(bgr, gray, cv::COLOR_BGR2GRAY); + return gray; +} + +// Suppress high-frequency noise before the Canny detector. +static cv::Mat gaussian_blur(cv::Mat gray) { + cv::Mat out; + cv::GaussianBlur(gray, out, {5, 5}, 0); + return out; +} + +// Detect strong edges; returns a binary mask (CV_8UC1). +static cv::Mat canny_edges(cv::Mat blurred) { + cv::Mat out; + cv::Canny(blurred, out, 50, 150); + return out; +} + +// Widen the edge mask for bolder cartoon outlines. +static cv::Mat dilate_edges(cv::Mat edges) { + static const cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, {3, 3}); + cv::Mat out; + cv::dilate(edges, out, kernel); + return out; +} + +// Burn black outlines into the quantised colour image. +static cv::Mat composite(cv::Mat quantised, cv::Mat thick_edges) { + cv::Mat result = quantised.clone(); + result.setTo(cv::Scalar(0, 0, 0), thick_edges); + return result; +} + +// ── DebugCanvas ──────────────────────────────────────────────────────────── +// +// Variadic MainThreadNode that accepts N cv::Mat inputs (any mix of BGR and +// greyscale) and tiles them into one debug window arranged as a +// ceil(sqrt(N)) × ceil(N/cols) grid. +// +// Template trick: MatArg aliases cv::Mat for all I, so the pack expansion +// MatArg<0>, MatArg<1>, ..., MatArg +// produces exactly N cv::Mat arguments — enough to drive the MainThreadNode +// base without manually spelling out the type N times. + +template +using MatArg = cv::Mat; + +template +class DebugCanvas; + +template> +struct DebugCanvasBase; + +template +struct DebugCanvasBase> { + using type = kpn::MainThreadNode, kpn::in<>, MatArg...>; +}; + +template +class DebugCanvas : public DebugCanvasBase::type { + using Base = typename DebugCanvasBase::type; +public: + static constexpr std::string_view label() { return "debug_canvas"; } + static constexpr std::size_t unique_tag = 0; + + explicit DebugCanvas(std::vector slot_labels = {}, + std::size_t fifo_cap = 4) + : Base(fifo_cap), labels_(std::move(slot_labels)) + { + cv::namedWindow("Debug Canvas", cv::WINDOW_NORMAL); + cv::resizeWindow("Debug Canvas", cols() * CW, rows() * CH); + } + + ~DebugCanvas() { cv::destroyWindow("Debug Canvas"); } + + // Called by MainThreadNode::step() with exactly N cv::Mat arguments. + template + bool operator()(Ms&&... mats) { + static_assert(sizeof...(Ms) == N, "DebugCanvas: wrong number of inputs"); + std::vector imgs; + imgs.reserve(N); + (imgs.push_back(to_bgr(std::forward(mats))), ...); + + cv::imshow("Debug Canvas", tile(imgs)); + int key = cv::waitKey(1); + if (key == 'q' || key == 27) return false; + try { return cv::getWindowProperty("Debug Canvas", cv::WND_PROP_VISIBLE) >= 1; } + catch (const cv::Exception&) { return false; } + } + +private: + static constexpr int CW = 640, CH = 480; + + static int cols() { return std::max(1, (int)std::ceil(std::sqrt((double)N))); } + static int rows() { return ((int)N + cols() - 1) / cols(); } + + std::vector labels_; + + static cv::Mat to_bgr(const cv::Mat& m) { + if (m.channels() == 1) { + cv::Mat bgr; + cv::cvtColor(m, bgr, cv::COLOR_GRAY2BGR); + return bgr; + } + return m; + } + + cv::Mat tile(const std::vector& imgs) const { + const int c = cols(), r = rows(); + cv::Mat canvas(r * CH, c * CW, CV_8UC3, cv::Scalar(30, 30, 30)); + for (int i = 0; i < (int)imgs.size(); ++i) { + if (imgs[i].empty()) continue; + cv::Mat cell = canvas(cv::Rect((i % c) * CW, (i / c) * CH, CW, CH)); + cv::Mat resized; + cv::resize(imgs[i], resized, {CW, CH}); + resized.copyTo(cell); + if (i < (int)labels_.size() && !labels_[i].empty()) + cv::putText(cell, labels_[i], {8, 36}, + cv::FONT_HERSHEY_SIMPLEX, 1.0, {0, 255, 255}, 2, + cv::LINE_AA); + } + return canvas; + } +}; + +// ── main ────────────────────────────────────────────────────────────────────── + +int main() { + using namespace kpn; + + auto src = make_node(4); + auto bilateral= make_node(4); + auto quant = make_node(4); + auto gray = make_node(4); + auto blur = make_node(4); + auto canny = make_node(4); + auto dilate = make_node(4); + auto comp = make_node(4); + + DebugCanvas<4> debug({"result", "quantised", "edges", "original"}); + + // make_network auto-inserts FanoutNode instances wherever a source port + // feeds more than one consumer: + // capture → 3-way fanout (bilateral, to_gray, debug[3]) + // quant → 2-way fanout (comp, debug[1]) + // canny → 2-way fanout (dilate, debug[2]) + auto net = make_network( + edge(src.output<0>(), bilateral.input<0>()), // frame → bilateral + edge(src.output<0>(), gray.input<0>()), // frame → to_gray + edge(src.output<0>(), debug.input<3>()), // frame → debug[3] original + edge(bilateral.output<0>(), quant.input<0>()), // smooth → quant + edge(quant.output<0>(), comp.input<0>()), // quant → comp + edge(quant.output<0>(), debug.input<1>()), // quant → debug[1] quantised + edge(gray.output<0>(), blur.input<0>()), // gray → blur + edge(blur.output<0>(), canny.input<0>()), // blurred→ canny + edge(canny.output<0>(), dilate.input<0>()), // edges → dilate + edge(canny.output<0>(), debug.input<2>()), // edges → debug[2] edges + edge(dilate.output<0>(), comp.input<1>()), // thick → comp + edge(comp.output<0>(), debug.input<0>()) // result → debug[0] result + ); + + std::cout << "Debug cell-shading pipeline running — press 'q' to stop.\n"; + std::cout << "Canvas: [0] result [1] quantised [2] edges [3] original\n"; + + net.start(); + while (debug.step()) + cv::waitKey(8); + net.stop(); + + return 0; +} diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 25641b0..ce5a8b0 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -29,6 +29,10 @@ if(OpenCV_FOUND) add_executable(12_static_cellshade 12_static_cellshade/main.cpp) target_link_libraries(12_static_cellshade PRIVATE kpn ${OpenCV_LIBS}) + + add_executable(13_debug_cellshade 13_debug_cellshade/main.cpp) + target_link_libraries(13_debug_cellshade PRIVATE kpn ${OpenCV_LIBS}) + if(KPN_WEB_DEBUG) kpn_target_enable_web_debug(09_opencv_cellshade) endif() diff --git a/include/kpn/fanout.hpp b/include/kpn/fanout.hpp index 25a655b..2afab26 100644 --- a/include/kpn/fanout.hpp +++ b/include/kpn/fanout.hpp @@ -44,7 +44,7 @@ using repeat_tuple_t = typename repeat_tuple::type; // .connect("fan", fan.output<0>(), "nodeA", nodeA.input<0>()) // .connect("fan", fan.output<1>(), "nodeB", nodeB.input<0>()) -template +template class FanoutNode : public INode { public: using args_tuple = std::tuple; @@ -141,8 +141,10 @@ private: auto cpu0 = NodeStats::cpu_now(); for (std::size_t i = 0; i < N; ++i) { - if (out_channels_[i]) - out_channels_[i]->push(val); + if (out_channels_[i]) { + try { out_channels_[i]->push(val); } + catch (const ChannelOverflowError&) {} // drop for this output independently + } } auto cpu1 = NodeStats::cpu_now(); @@ -150,8 +152,6 @@ private: stats_.record_exec(duration_t(t2 - t1), duration_t(t1 - t0), cpu0, cpu1); } catch (const ChannelClosedError&) { break; - } catch (const ChannelOverflowError& e) { - std::cerr << "[kpn] fanout overflow: " << e.what() << "\n"; } } } @@ -169,7 +169,7 @@ private: template FanoutNode make_fanout(std::size_t fifo_capacity = 5) { - return FanoutNode(fifo_capacity); + return FanoutNode(fifo_capacity); } } // namespace kpn diff --git a/include/kpn/tmp/fanout_groups.hpp b/include/kpn/tmp/fanout_groups.hpp index f045aa9..e6bb342 100644 --- a/include/kpn/tmp/fanout_groups.hpp +++ b/include/kpn/tmp/fanout_groups.hpp @@ -116,7 +116,7 @@ struct FanoutPlaceholder { namespace kpn { // Forward declaration — FanoutNode is defined in fanout.hpp. -template class FanoutNode; +template class FanoutNode; } // namespace kpn namespace kpn::tmp { @@ -172,7 +172,7 @@ template struct fanout_to_dst_edges, DstTail...>, I> { - using head_edge = SimpleEdge, I, DstNodeT, DstI>; + using head_edge = SimpleEdge, I, DstNodeT, DstI>; using rest = typename fanout_to_dst_edges, I+1>::type; using type = append_t; @@ -282,7 +282,7 @@ struct process_edge { using DstDescs = typename collect_dsts::type; static constexpr std::size_t fid = State::next_id; using fanout_type = FanoutPlaceholder; - using src_to_fan = SimpleEdge, 0>; + using src_to_fan = SimpleEdge, 0>; using fan_to_dsts = typename fanout_to_dst_edges::type; using fanout_edges = concat_t, fan_to_dsts>; using new_fanouts = append_t; @@ -340,10 +340,9 @@ struct to_fanout_tuple> { using type = std::tuple<>; }; template struct to_fanout_tuple, Rest...>> { using rest = typename to_fanout_tuple>::type; - // prepend FanoutNode template struct prepend; template struct prepend> { - using type = std::tuple, Ts...>; + using type = std::tuple, Ts...>; }; using type = typename prepend::type; }; diff --git a/tests/test_static_network.cpp b/tests/test_static_network.cpp index b5f82a5..3dc2b26 100644 --- a/tests/test_static_network.cpp +++ b/tests/test_static_network.cpp @@ -175,6 +175,70 @@ TEST_CASE("static_network: same function distinguished by UniqueTag", "[static_n net.stop(); } +TEST_CASE("static_network: two independent fan-outs of the same element type are wired independently", "[static_network]") { + // Both src_a and src_b fan out to two consumers each. + // Without the FanoutId fix, both would produce FanoutNode — the same + // C++ type — and find_node would wire all four consumers to the first instance. + auto src_a = make_node(8); + auto src_b = make_node(8); + auto cA = make_node(8); + auto cB = make_node(8); + auto cC = make_node(8); + auto cD = make_node(8); + + Channel outA(8), outB(8), outC(8), outD(8); + cA.set_output_channel<0>(&outA); + cB.set_output_channel<0>(&outB); + cC.set_output_channel<0>(&outC); + cD.set_output_channel<0>(&outD); + + auto net = make_network( + edge(src_a.output<0>(), cA.input<0>()), // src_a → FanoutNode → cA, cB + edge(src_a.output<0>(), cB.input<0>()), + edge(src_b.output<0>(), cC.input<0>()), // src_b → FanoutNode → cC, cD + edge(src_b.output<0>(), cD.input<0>()) + ); + + net.start(); + src_a.input_channel<0>().push(0); // 0 → +10=10 → {+1=11, *2=20} + src_b.input_channel<0>().push(5); // 5 → negate=-5 → {*3=-15, ^2=25} + + REQUIRE(outA.pop() == 11); + REQUIRE(outB.pop() == 20); + REQUIRE(outC.pop() == -15); + REQUIRE(outD.pop() == 25); + net.stop(); +} + +TEST_CASE("static_network: code reuse - same function at corresponding stages of parallel branches", "[static_network]") { + // Both branches use increment and multiply2 — code reuse via distinct UniqueTag. + // Topology: src → fanout → { increment(tag=1) → multiply2(tag=1) → outA } + // → { increment(tag=2) → multiply2(tag=2) → outB } + auto src = make_node(8); + auto incA = make_node(8); + auto mulA = make_node(8); + auto incB = make_node(8); + auto mulB = make_node(8); + + Channel outA(8), outB(8); + mulA.set_output_channel<0>(&outA); + mulB.set_output_channel<0>(&outB); + + auto net = make_network( + edge(src.output<0>(), incA.input<0>()), + edge(src.output<0>(), incB.input<0>()), + edge(incA.output<0>(), mulA.input<0>()), + edge(incB.output<0>(), mulB.input<0>()) + ); + + net.start(); + src.input_channel<0>().push(0); // 0 → +10=10 → both: +1=11 → *2=22 + + REQUIRE(outA.pop() == 22); + REQUIRE(outB.pop() == 22); + net.stop(); +} + TEST_CASE("static_network: label is accessible as static member", "[static_network]") { using MyNode = decltype(make_node(5)); REQUIRE(MyNode::label() == "my_node");