Performance improvements, better readme and complete python bindings
🧪 Test / test (push) Failing after 28m30s
🧪 Test / test (push) Failing after 28m30s
This commit is contained in:
@@ -7,16 +7,20 @@
|
||||
//
|
||||
// [produce] --int--> [double_it] --int--> [print_it]
|
||||
|
||||
// [snippet: basic_node_fns]
|
||||
static int produce() { return 42; }
|
||||
static int double_it(int x) { return x * 2; }
|
||||
static void print_it(int x) { std::cout << "result: " << x << '\n'; }
|
||||
// [/snippet: basic_node_fns]
|
||||
|
||||
int main() {
|
||||
using namespace kpn;
|
||||
|
||||
// [snippet: index_only_nodes]
|
||||
auto src = make_node<produce>(5);
|
||||
auto dbl = make_node<double_it>(5);
|
||||
auto sink = make_node<print_it>(5);
|
||||
// [/snippet: index_only_nodes]
|
||||
|
||||
// Wire channels
|
||||
auto& dbl_in = dbl.input_channel<0>();
|
||||
@@ -24,6 +28,7 @@ int main() {
|
||||
src.set_output_channel<0>(&dbl_in);
|
||||
dbl.set_output_channel<0>(&sink_in);
|
||||
|
||||
// [snippet: network_build]
|
||||
Network net;
|
||||
net.add("src", src)
|
||||
.add("dbl", dbl)
|
||||
@@ -35,4 +40,5 @@ int main() {
|
||||
net.start();
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
net.stop();
|
||||
// [/snippet: network_build]
|
||||
}
|
||||
|
||||
@@ -54,17 +54,18 @@ static void report(int count, std::vector<std::string> words) {
|
||||
int main() {
|
||||
using namespace kpn;
|
||||
|
||||
// [snippet: named_port_creation]
|
||||
// tokenise: no inputs, one named output "words"
|
||||
auto tok = make_node<tokenise>(out<"words">{}, 4);
|
||||
|
||||
// count_words: named input "words", named outputs "count" and "words"
|
||||
auto cnt = make_node<count_words>(in<"words">{}, out<"count", "words">{}, 4);
|
||||
|
||||
// report: two named inputs — note the function takes (int, vector<string>)
|
||||
// so we need two separate input ports wired independently
|
||||
// For a two-input sink we wire each output of cnt to a different input of report
|
||||
// report: two named inputs
|
||||
auto snk = make_node<report>(in<"count", "words">{}, 4);
|
||||
// [/snippet: named_port_creation]
|
||||
|
||||
// [snippet: named_port_network]
|
||||
Network net;
|
||||
net.add("tok", tok)
|
||||
.add("cnt", cnt)
|
||||
@@ -77,4 +78,5 @@ int main() {
|
||||
net.start();
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(500));
|
||||
net.stop();
|
||||
// [/snippet: named_port_network]
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ static std::string generate() {
|
||||
return pairs[gen_index++ % 5];
|
||||
}
|
||||
|
||||
// [snippet: multi_output_fn]
|
||||
// Multi-output: returns (key, value) as a tuple — KPN++ routes each element
|
||||
// to its own output port automatically.
|
||||
static std::tuple<std::string, std::string> parse(std::string kv) {
|
||||
@@ -40,6 +41,7 @@ static std::tuple<std::string, std::string> parse(std::string kv) {
|
||||
if (sep == std::string::npos) return {kv, ""};
|
||||
return {kv.substr(0, sep), kv.substr(sep + 1)};
|
||||
}
|
||||
// [/snippet: multi_output_fn]
|
||||
|
||||
static void print_key(std::string key) {
|
||||
std::cout << "KEY → " << key << '\n';
|
||||
@@ -54,6 +56,7 @@ static void print_value(std::string value) {
|
||||
int main() {
|
||||
using namespace kpn;
|
||||
|
||||
// [snippet: fanout_network]
|
||||
auto gen = make_node<generate>(out<"kv">{}, 4);
|
||||
auto par = make_node<parse> (in<"kv">{}, out<"key", "value">{}, 4);
|
||||
auto keys = make_node<print_key> (in<"key">{}, 4);
|
||||
@@ -72,4 +75,5 @@ int main() {
|
||||
net.start();
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(600));
|
||||
net.stop();
|
||||
// [/snippet: fanout_network]
|
||||
}
|
||||
|
||||
@@ -34,12 +34,14 @@ struct Tag {
|
||||
int value = 0;
|
||||
};
|
||||
|
||||
// [snippet: storage_policy_spec]
|
||||
// Override: store Tag by value despite being a struct
|
||||
// (it's trivially copyable and small — this just makes the policy explicit)
|
||||
template<>
|
||||
struct kpn::channel_storage_policy<Tag> {
|
||||
static constexpr bool by_value = true;
|
||||
};
|
||||
// [/snippet: storage_policy_spec]
|
||||
|
||||
// ── Node functions ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ int main() {
|
||||
|
||||
Network net;
|
||||
|
||||
// [snippet: diagnostics_handler]
|
||||
// Custom diagnostics handler — fires on the watchdog interval.
|
||||
// Print a concise one-liner rather than the full table.
|
||||
net.set_diagnostics_handler([](const std::vector<NodeSnapshot>& nodes,
|
||||
@@ -57,6 +58,7 @@ int main() {
|
||||
<< "overflows=" << c.overflows;
|
||||
std::cout << '\n';
|
||||
});
|
||||
// [/snippet: diagnostics_handler]
|
||||
|
||||
net.set_watchdog_interval(std::chrono::milliseconds(200));
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
09_opencv_cellshade/example_hybrid.py
|
||||
──────────────────────────────────────
|
||||
Hybrid cell-shading pipeline: C++ nodes handle capture, grayscale conversion,
|
||||
and edge detection; a Python/numpy function replaces the C++ quantise node;
|
||||
Python drives the display loop using cv2.
|
||||
|
||||
Pipeline:
|
||||
┌─[py_quantise]──────────────┐
|
||||
[CaptureNode] ─────┤ ├──[CompositeNode]──result──▶ cv2.imshow
|
||||
out0=colour └─[ToGrayNode]─[EdgesNode]───┘ edges───▶ cv2.imshow
|
||||
out1=grey
|
||||
|
||||
For a pure-C++ version see main.cpp; for the C++ static-network version see
|
||||
12_static_cellshade/main.cpp.
|
||||
|
||||
Press 'q' or Esc to stop.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Adjust path to wherever CMake placed the .so
|
||||
BUILD_DIR = os.environ.get("KPN_BUILD_DIR",
|
||||
os.path.join(os.path.dirname(__file__),
|
||||
"../../build/examples"))
|
||||
sys.path.insert(0, BUILD_DIR)
|
||||
|
||||
import numpy as np
|
||||
import cv2
|
||||
import kpn_opencv as kpn
|
||||
|
||||
|
||||
# ── Python node: replace the C++ quantise with numpy ─────────────────────────
|
||||
# Receives and returns a BGR numpy array (H×W×3 uint8).
|
||||
|
||||
def py_quantise(bgr: np.ndarray) -> np.ndarray:
|
||||
levels = 4
|
||||
step = 256 // levels
|
||||
q = (bgr.astype(np.int32) // step) * step + (step // 2)
|
||||
return q.clip(0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
# ── Build network ─────────────────────────────────────────────────────────────
|
||||
|
||||
net = kpn.Network()
|
||||
|
||||
net.add("src", kpn.make_capture()) # out0=colour, out1=grey
|
||||
net.add_node("quant", py_quantise, # Python node — numpy in/out
|
||||
inputs=["mat"], outputs=["mat"])
|
||||
net.add("gray", kpn.make_to_gray()) # in0=bgr → out0=gray
|
||||
net.add("edges", kpn.make_edges()) # in0=gray → out0=edge_mask
|
||||
net.add("comp", kpn.make_composite()) # in0=edge_mask, in1=colour
|
||||
# out0=result, out1=edge_mask
|
||||
|
||||
# src.colour → py_quantise
|
||||
net.connect("src", 0, "quant", 0)
|
||||
# src.grey → to_gray
|
||||
net.connect("src", 1, "gray", 0)
|
||||
# gray → edges
|
||||
net.connect("gray", 0, "edges", 0)
|
||||
# quantised colour → composite.colour (input slot 1)
|
||||
net.connect("quant", 0, "comp", 1)
|
||||
# edge mask → composite.edges (input slot 0)
|
||||
net.connect("edges", 0, "comp", 0)
|
||||
|
||||
net.build()
|
||||
net.start()
|
||||
|
||||
# ── Display loop (drives GUI on this thread) ──────────────────────────────────
|
||||
|
||||
cv2.namedWindow("Cell Shade (Python quant)", cv2.WINDOW_NORMAL)
|
||||
cv2.namedWindow("Edge Mask", cv2.WINDOW_NORMAL)
|
||||
cv2.resizeWindow("Cell Shade (Python quant)", 1280, 720)
|
||||
cv2.resizeWindow("Edge Mask", 640, 360)
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Blocking reads — GIL released while waiting so C++ threads can run
|
||||
result = net.read("comp", 0) # composite frame (BGR numpy array)
|
||||
edges = net.read("comp", 1) # edge mask (grayscale numpy array)
|
||||
|
||||
cv2.imshow("Cell Shade (Python quant)", result)
|
||||
cv2.imshow("Edge Mask", cv2.cvtColor(edges, cv2.COLOR_GRAY2BGR))
|
||||
|
||||
key = cv2.waitKey(1)
|
||||
if key in (ord('q'), 27):
|
||||
break
|
||||
|
||||
# Check windows still open
|
||||
try:
|
||||
if cv2.getWindowProperty("Cell Shade (Python quant)",
|
||||
cv2.WND_PROP_VISIBLE) < 1:
|
||||
break
|
||||
except cv2.error:
|
||||
break
|
||||
|
||||
finally:
|
||||
net.stop()
|
||||
cv2.destroyAllWindows()
|
||||
del net # let C++ destructor run before nanobind tears down
|
||||
@@ -0,0 +1,177 @@
|
||||
#define KPN_BUILD_PYTHON
|
||||
#include <kpn/python/auto_bind.hpp>
|
||||
|
||||
#include <nanobind/ndarray.h>
|
||||
|
||||
#include <opencv2/core.hpp>
|
||||
#include <opencv2/imgproc.hpp>
|
||||
#include <opencv2/videoio.hpp>
|
||||
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
#include <tuple>
|
||||
|
||||
namespace nb = nanobind;
|
||||
using namespace kpn;
|
||||
using namespace kpn::python;
|
||||
|
||||
// ── PythonConverter<cv::Mat> ──────────────────────────────────────────────────
|
||||
// Converts cv::Mat ↔ numpy array (uint8, HxW or HxWxC shape).
|
||||
//
|
||||
// to_python: clones the mat onto the heap; the numpy array owns it via a
|
||||
// capsule deleter — no shared cv::Mat refcount dangling after the Variant dies.
|
||||
// from_python: calls numpy.ascontiguousarray, then clones into an owned cv::Mat.
|
||||
|
||||
namespace kpn {
|
||||
|
||||
template<> struct PythonConverter<cv::Mat> {
|
||||
static constexpr const char* type_name = "mat";
|
||||
|
||||
static nb::object to_python(const cv::Mat& m) {
|
||||
// Must be called with the GIL held (always true: called from read() or
|
||||
// from within the gil_scoped_acquire block in PyNode::run_loop).
|
||||
auto np = nb::module_::import_("numpy");
|
||||
cv::Mat c = m.clone(); // ensure contiguous, independently owned
|
||||
nb::bytes raw(reinterpret_cast<const char*>(c.data),
|
||||
c.total() * c.elemSize());
|
||||
nb::object arr = np.attr("frombuffer")(raw, "uint8");
|
||||
int H = c.rows, W = c.cols, C = c.channels();
|
||||
arr = arr.attr("reshape")(
|
||||
C > 1 ? nb::make_tuple(H, W, C) : nb::make_tuple(H, W));
|
||||
return arr.attr("copy")(); // writable, lifetime-independent copy
|
||||
}
|
||||
|
||||
static cv::Mat from_python(nb::object o) {
|
||||
auto np = nb::module_::import_("numpy");
|
||||
// Ensure contiguous uint8 layout (in-place if already compatible)
|
||||
nb::object arr = np.attr("ascontiguousarray")(o, "uint8");
|
||||
auto shape = nb::cast<std::vector<int>>(arr.attr("shape"));
|
||||
if (shape.size() < 2 || shape.size() > 3)
|
||||
throw std::runtime_error(
|
||||
"cv::Mat from_python: expected 2D (H×W) or 3D (H×W×C) uint8 array");
|
||||
int H = shape[0], W = shape[1];
|
||||
int C = (shape.size() == 3) ? shape[2] : 1;
|
||||
int type = C > 1 ? CV_8UC(C) : CV_8UC1;
|
||||
|
||||
// Cast to ndarray to get the raw data pointer
|
||||
auto binfo = nb::cast<nb::ndarray<nb::numpy, uint8_t>>(arr);
|
||||
cv::Mat wrap(H, W, type, binfo.data());
|
||||
return wrap.clone(); // own the pixel data
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace kpn
|
||||
|
||||
// ── Pipeline functions ────────────────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
static std::tuple<cv::Mat, 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(), frame.clone()};
|
||||
}
|
||||
|
||||
static cv::Mat to_gray(cv::Mat bgr) {
|
||||
cv::Mat gray;
|
||||
cv::cvtColor(bgr, gray, cv::COLOR_BGR2GRAY);
|
||||
return gray;
|
||||
}
|
||||
|
||||
static cv::Mat edges_fn(cv::Mat gray) {
|
||||
cv::Mat blurred, mask;
|
||||
cv::GaussianBlur(gray, blurred, {5, 5}, 0);
|
||||
cv::Canny(blurred, mask, 50, 150);
|
||||
return mask;
|
||||
}
|
||||
|
||||
static cv::Mat quantise(cv::Mat bgr) {
|
||||
constexpr int levels = 4;
|
||||
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<uchar>(i) = cv::saturate_cast<uchar>(
|
||||
std::floor(i / step) * step + step / 2.0);
|
||||
return l;
|
||||
}();
|
||||
cv::Mat out;
|
||||
cv::LUT(bgr, lut, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Returns composite frame AND edge mask so the display node can show both
|
||||
// without needing a fan-out on the edges channel.
|
||||
static std::tuple<cv::Mat, cv::Mat> composite(cv::Mat edge_mask, cv::Mat colour) {
|
||||
cv::Mat result = colour.clone();
|
||||
result.setTo(cv::Scalar(0, 0, 0), edge_mask);
|
||||
return {result, edge_mask};
|
||||
}
|
||||
|
||||
// ── Registry ──────────────────────────────────────────────────────────────────
|
||||
// Variant deduced as std::variant<cv::Mat> — every node uses only cv::Mat.
|
||||
|
||||
using CvNodes = NodeRegistry<
|
||||
Entry<capture, "capture">,
|
||||
Entry<to_gray, "to_gray">,
|
||||
Entry<edges_fn, "edges">,
|
||||
Entry<quantise, "quantise">,
|
||||
Entry<composite, "composite">
|
||||
>;
|
||||
|
||||
// ── Module ────────────────────────────────────────────────────────────────────
|
||||
|
||||
NB_MODULE(kpn_opencv, m) {
|
||||
m.doc() = "KPN++ OpenCV bindings for the cell-shading pipeline";
|
||||
|
||||
// Registers: Network, INode, CaptureNode, ToGrayNode, EdgesNode,
|
||||
// QuantiseNode, CompositeNode, and make_<name>() factories.
|
||||
// Network.add_node(name, callable, inputs=["mat"], outputs=["mat"])
|
||||
// accepts Python callables that receive/return numpy uint8 arrays.
|
||||
bind_network<CvNodes>(m);
|
||||
|
||||
// Note: bind_debug is omitted here — cv::Mat functions cannot be called
|
||||
// directly from Python without the variant/network machinery. Use
|
||||
// net.write() + net.read() to inject/inspect individual nodes instead.
|
||||
}
|
||||
@@ -8,6 +8,12 @@
|
||||
#include <thread>
|
||||
#include <chrono>
|
||||
|
||||
// Teach KPN how many bytes a cv::Mat actually carries (header + pixel data).
|
||||
template<>
|
||||
struct kpn::ChannelDataSize<cv::Mat> {
|
||||
static std::size_t bytes(const cv::Mat& m) { return m.total() * m.elemSize(); }
|
||||
};
|
||||
|
||||
// ── Cell-shading pipeline ─────────────────────────────────────────────────────
|
||||
//
|
||||
// [capture] --"colour"--> [quantise] ──────────────────────────┐
|
||||
@@ -32,6 +38,7 @@ static cv::Mat make_gradient(int W, int H) {
|
||||
|
||||
// ── Pipeline functions ────────────────────────────────────────────────────────
|
||||
|
||||
// [snippet: capture_fn]
|
||||
static std::tuple<cv::Mat, cv::Mat> capture() {
|
||||
constexpr int W = 640, H = 480;
|
||||
static cv::VideoCapture cap;
|
||||
@@ -68,6 +75,7 @@ static std::tuple<cv::Mat, cv::Mat> capture() {
|
||||
}
|
||||
return {frame.clone(), frame.clone()};
|
||||
}
|
||||
// [/snippet: capture_fn]
|
||||
|
||||
static cv::Mat to_gray(cv::Mat bgr) {
|
||||
cv::Mat gray;
|
||||
@@ -112,6 +120,7 @@ static std::tuple<cv::Mat, cv::Mat> composite(cv::Mat edge_mask, cv::Mat colour)
|
||||
// The constructor opens both windows on the main thread (Wayland requirement).
|
||||
// operator() is called by step() whenever both channels have a frame ready.
|
||||
|
||||
// [snippet: display_node]
|
||||
class DisplayNode : public kpn::MainThreadNode<DisplayNode,
|
||||
kpn::in<"composite", "edges">,
|
||||
cv::Mat, cv::Mat> {
|
||||
@@ -141,12 +150,14 @@ private:
|
||||
catch (const cv::Exception&) { return false; }
|
||||
}
|
||||
};
|
||||
// [/snippet: display_node]
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
int main() {
|
||||
using namespace kpn;
|
||||
|
||||
// [snippet: opencv_network]
|
||||
auto src = make_node<capture> (out<"colour","grey">{}, 8);
|
||||
auto gray_node = make_node<to_gray> (in<"bgr">{}, out<"gray">{}, 8);
|
||||
auto edge_node = make_node<edges_fn> (in<"gray">{}, out<"edges">{}, 8);
|
||||
@@ -171,13 +182,17 @@ int main() {
|
||||
.connect("comp", comp.template output<"result">(), "display", disp.template input<"composite">())
|
||||
.connect("comp", comp.template output<"edges">(), "display", disp.template input<"edges">())
|
||||
.build();
|
||||
// [/snippet: opencv_network]
|
||||
|
||||
net.set_watchdog_interval(std::chrono::milliseconds(5000));
|
||||
#ifdef KPN_WEB_DEBUG
|
||||
net.set_web_debug_port(9090);
|
||||
#endif
|
||||
|
||||
std::cout << "Cell-shading pipeline running. Press 'q' to stop.\n";
|
||||
std::cout << "Web debug UI: http://localhost:9090\n";
|
||||
|
||||
// [snippet: main_thread_step]
|
||||
net.start();
|
||||
|
||||
// Main thread drives display — imshow/waitKey stay on the GUI thread.
|
||||
@@ -186,5 +201,6 @@ int main() {
|
||||
cv::waitKey(8); // yield event loop when no frame ready
|
||||
|
||||
net.stop();
|
||||
// [/snippet: main_thread_step]
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -21,14 +21,18 @@ if(KPN_WEB_DEBUG)
|
||||
target_link_libraries(14_debug_hub PRIVATE kpn)
|
||||
kpn_target_enable_web_debug(14_debug_hub)
|
||||
endif()
|
||||
# 07 and 08 require the Python bindings — only add if built
|
||||
if(KPN_BUILD_PYTHON)
|
||||
# These are Python scripts, not compiled targets — installed alongside kpn_python
|
||||
endif()
|
||||
# 07 and 08 are Python scripts — no compiled target needed.
|
||||
|
||||
# 09 requires OpenCV — only build if found
|
||||
find_package(OpenCV QUIET COMPONENTS core imgproc highgui videoio)
|
||||
if(OpenCV_FOUND)
|
||||
# Hybrid Python example: kpn_opencv module (requires both OpenCV and nanobind)
|
||||
if(KPN_BUILD_PYTHON)
|
||||
nanobind_add_module(kpn_opencv 09_opencv_cellshade/kpn_opencv.cpp)
|
||||
target_link_libraries(kpn_opencv PRIVATE kpn ${OpenCV_LIBS})
|
||||
target_compile_definitions(kpn_opencv PRIVATE KPN_BUILD_PYTHON)
|
||||
message(STATUS "KPN++ kpn_opencv Python module: building (OpenCV ${OpenCV_VERSION})")
|
||||
endif()
|
||||
add_executable(09_opencv_cellshade 09_opencv_cellshade/main.cpp)
|
||||
target_link_libraries(09_opencv_cellshade PRIVATE kpn ${OpenCV_LIBS})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user