CvEditor 节点开发实战:从写一个模糊节点到 YOLO 检测节点
CvEditor 节点开发实战:从写一个模糊节点到 YOLO 检测节点
前言
在上一篇里,我介绍了 CvEditor 的整体架构设计。这篇来聊聊最实际的问题:怎么写一个节点?
我会从最简单的模糊节点开始,到需要初始化模型的 YOLO 检测节点,再到带内部状态的跟踪节点。每个示例都是完整可运行的代码,不是伪代码。
写节点的过程,也是我重新理解 OpenCV API 的过程。很多函数之前只在命令行里用过,现在要把它包装成可交互的、有 UI 控件的"积木块",思考方式很不一样。
节点是什么
在 CvEditor 里,每个节点就是一个图像处理单元:
┌─────────────────────────┐
│ Node Name │
├─────────────────────────┤
│ [Input] input_ ─┼──> // 接收图像
│ [Input] threshold_ ─┼──> // 参数:阈值
│ [Output] output_ ─┼──> // 输出结果
└─────────────────────────┘
一个节点由三部分组成:
- 输入属性:接收上游数据或参数
- 输出属性:向下游传递处理结果
- 执行逻辑:
Exec()方法,节点的核心
节点基类速览
所有节点都继承自 Node 基类(include/editor/sdk/node.h):
class EDITOR_API Node {
public:
Node(Context *context, const std::string &name);
virtual ~Node() = default;
virtual void Draw(); // 绘制节点 UI
virtual void Preview(); // 预览窗口(可选)
virtual void Exec(); // 执行逻辑(核心,必须实现)
virtual void Start(); // 启动时调用(可选)
virtual void Stop(); // 停止时调用(可选)
virtual bool Load(const Json::Value& value);
virtual bool Save(Json::Value& value);
void AddAttribute(const AttributePtr &attr);
void SetWidth(float width) noexcept;
Context *GetContext() noexcept;
};
通常只需要实现 Exec(),需要管理资源的话加上 Start()/Stop()。
属性系统:节点的"手脚"
属性系统是 CvEditor 最核心的抽象之一。每种属性类型对应一种 UI 控件和一种数据类型:
| 属性类型 | UI 控件 | 使用场景 |
|---|---|---|
InputAttribute |
连接端口 | 数据输入 |
OutputAttribute |
连接端口 | 数据输出 |
IntInputAttribute |
拖拽滑块 | 整数参数 |
FloatInputAttribute |
浮点滑块 | 浮点参数 |
BoolInputAttribute |
复选框 | 开关选项 |
StringInputAttribute |
文本框 | 文本参数 |
PathInputAttribute |
文件选择器 | 文件路径 |
EnumInputAttribute |
下拉菜单 | 枚举选项 |
CvMatOutputAttribute |
图像预览 | 图像输出 |
CvSizeInputAttribute |
宽高输入 | 尺寸参数 |
CvRectInputAttribute |
矩形输入 | ROI 参数 |
CvScalarInputAttribute |
颜色选择 | 颜色参数 |
这种设计让 Node 类保持简洁——只需要声明"我要什么",不用关心"怎么画"。
属性创建示例
// 在构造函数中创建属性
MyNode::MyNode(Context* context, const std::string& name)
: Node(context, name) {
SetWidth(180.0f);
// 图像输入
auto input = std::make_shared<InputAttribute>(context, "input", cv::Mat());
AddAttribute(input);
// 整数参数:名称、默认值、步进、快捷步进
auto threshold = std::make_shared<IntInputAttribute>(
context, "threshold", 127, 1, 100);
AddAttribute(threshold);
// 图像输出(带预览)
auto output = std::make_shared<CvMatOutputAttribute>(context, "output");
AddAttribute(output);
}
读写属性值
void MyNode::Exec() {
auto input_image = input_->GetValueAs<cv::Mat>();
int thresh = threshold_->GetValueAs<int>();
// 处理...
output_->SetValue(std::move(result)); // 移动语义,避免复制
}
示例一:最简单的模糊节点
这是最容易理解的节点类型——不需要生命周期管理,每帧独立处理。
// blur_node.h
class BlurNode : public Node {
public:
BlurNode(Context* context, const std::string& name)
: Node(context, name) {
SetWidth(160.0f);
input_ = std::make_shared<InputAttribute>(context, "input", cv::Mat());
AddAttribute(input_);
ksize_ = std::make_shared<CvSizeInputAttribute>(
context, "ksize", cv::Size(5, 5));
AddAttribute(ksize_);
output_ = std::make_shared<CvMatOutputAttribute>(context, "output");
AddAttribute(output_);
}
void Exec() override {
auto input_image = input_->GetValueAs<cv::Mat>();
if (input_image.empty()) return;
cv::Mat output_image;
auto ksize = ksize_->GetValueAs<cv::Size>();
cv::blur(input_image, output_image, ksize);
output_->SetValue(std::move(output_image));
}
private:
std::shared_ptr<InputAttribute> input_;
std::shared_ptr<CvSizeInputAttribute> ksize_;
std::shared_ptr<CvMatOutputAttribute> output_;
};
就这么简单。30 行代码,一个可交互的模糊节点就完成了。用户在编辑器里拖一个出来,调调 ksize 滑块,结果实时可见。
踩坑:一开始我忘了检查
input_image.empty(),导致上游没连的时候直接崩。后来养成了习惯:Exec()第一行永远是空值检查。
示例二:图像二值化节点
这个节点展示了几种常用属性的组合:浮点参数、枚举选择。
// threshold_node.h
class MyThresholdNode : public Node {
public:
MyThresholdNode(Context* context, const std::string& name)
: Node(context, name) {
SetWidth(180.0f);
input_ = std::make_shared<InputAttribute>(context, "input", cv::Mat());
AddAttribute(input_);
thresh_ = std::make_shared<FloatInputAttribute>(
context, "threshold", 127.0f, 1.0f, 10.0f);
AddAttribute(thresh_);
maxval_ = std::make_shared<FloatInputAttribute>(
context, "maxval", 255.0f, 1.0f, 10.0f);
AddAttribute(maxval_);
// 枚举属性:提供下拉选项
static EnumInputAttribute::EnumValues values = {
{"BINARY", cv::THRESH_BINARY},
{"BINARY_INV", cv::THRESH_BINARY_INV},
{"TRUNC", cv::THRESH_TRUNC},
{"TOZERO", cv::THRESH_TOZERO},
{"TOZERO_INV", cv::THRESH_TOZERO_INV},
{"OTSU", cv::THRESH_OTSU | cv::THRESH_BINARY},
};
type_ = std::make_shared<EnumInputAttribute>(context, "type", values, 0);
AddAttribute(type_);
output_ = std::make_shared<CvMatOutputAttribute>(context, "output");
AddAttribute(output_);
}
void Exec() override {
auto input_image = input_->GetValueAs<cv::Mat>();
if (input_image.empty()) {
output_->SetValue(cv::Mat());
return;
}
auto thresh = thresh_->GetValueAs<float>();
auto maxval = maxval_->GetValueAs<float>();
auto type = type_->GetValueAs<int>();
cv::Mat output_image;
cv::threshold(input_image, output_image, thresh, maxval, type);
output_->SetValue(std::move(output_image));
}
private:
std::shared_ptr<InputAttribute> input_;
std::shared_ptr<FloatInputAttribute> thresh_;
std::shared_ptr<FloatInputAttribute> maxval_;
std::shared_ptr<EnumInputAttribute> type_;
std::shared_ptr<CvMatOutputAttribute> output_;
};
EnumInputAttribute 是我用得最多的属性之一。OpenCV 的很多函数都有一大堆 flag,用枚举下拉菜单比手动输入整数友好太多了。
示例三:YOLO 检测节点
这个节点需要加载 ONNX 模型,涉及 Start()/Stop() 生命周期管理。也是我花了最多时间调通的节点。
// yolo_node.h
class MyYoloNode : public Node {
public:
MyYoloNode(Context* context, const std::string& name)
: Node(context, name) {
SetWidth(180.0f);
input_image_ = std::make_shared<InputAttribute>(context, "input", cv::Mat());
AddAttribute(input_image_);
detections_ = std::make_shared<OutputAttribute>(
context, "detections", std::vector<OutputParams>());
AddAttribute(detections_);
output_image_ = std::make_shared<CvMatOutputAttribute>(context, "output");
AddAttribute(output_image_);
model_path_ = std::make_shared<PathInputAttribute>(
context, "model_path", "./data/model/yolov8n.onnx");
model_path_->SetMode(PathInputAttribute::Mode::Open);
model_path_->SetFilter("onnx");
AddAttribute(model_path_);
is_cuda_ = std::make_shared<BoolInputAttribute>(context, "is_cuda", false);
AddAttribute(is_cuda_);
}
void Start() override {
if (!detector_) {
detector_ = std::make_unique<Yolov8Onnx>();
auto model_path = model_path_->GetString();
auto is_cuda = is_cuda_->GetValueAs<bool>();
if (!model_path.empty()) {
detector_->ReadModel(model_path, is_cuda, 0, true);
}
}
}
void Stop() override {
detector_.reset();
}
void Exec() override {
auto input_image = input_image_->GetValueAs<cv::Mat>();
if (input_image.empty() || !detector_) {
output_image_->SetValue(cv::Mat());
return;
}
std::vector<OutputParams> results;
detector_->OnnxDetect(input_image, results);
// 在图像上绘制检测框
cv::Mat output_frame = input_image.clone();
for (auto& result : results) {
cv::Scalar color(rand() % 256, rand() % 256, rand() % 256);
cv::rectangle(output_frame, result.box, color, 3);
std::string label = cv::format("%.2f", result.confidence);
cv::putText(output_frame, label,
cv::Point(result.box.x, result.box.y - 5),
cv::FONT_HERSHEY_SIMPLEX, 0.75, cv::Scalar(0, 0, 0), 2);
}
output_image_->SetValue(std::move(output_frame));
detections_->SetValue(std::move(results));
}
private:
std::shared_ptr<InputAttribute> input_image_;
std::shared_ptr<OutputAttribute> detections_;
std::shared_ptr<CvMatOutputAttribute> output_image_;
std::shared_ptr<PathInputAttribute> model_path_;
std::shared_ptr<BoolInputAttribute> is_cuda_;
std::unique_ptr<Yolov8Onnx> detector_;
};
踩坑:模型加载很慢(几秒),如果在
Exec()里做延迟初始化,第一帧会卡住。后来改为在Start()里加载,用户点"播放"时等一下就好,运行时不会卡。
示例四:带状态的跟踪节点
跟踪节点需要维护内部状态(跟踪器实例),同时提供自定义预览面板。这是一个比较复杂的节点类型。
class MultiTrackerNode : public Node {
public:
MultiTrackerNode(Context* context, const std::string& name)
: Node(context, name) {
SetWidth(160.0f);
contours_ = std::make_shared<InputAttribute>(context, "contours", ContoursResults());
AddAttribute(contours_);
max_time_lost_ = std::make_shared<IntInputAttribute>(context, "max_time_lost", 30);
AddAttribute(max_time_lost_);
track_thresh_ = std::make_shared<FloatInputAttribute>(context, "track_thresh", 0.1f);
AddAttribute(track_thresh_);
match_thresh_ = std::make_shared<FloatInputAttribute>(context, "match_thresh", 0.8f);
AddAttribute(match_thresh_);
tracks_ = std::make_shared<OutputAttribute>(
context, "tracks", std::vector<TrackResult>());
AddAttribute(tracks_);
}
void Start() override {
tracker_ = std::make_shared<bytetrack_cpp::BYTETracker>();
}
void Exec() override {
auto contours = contours_->GetValueAs<ContoursResults>();
// 更新跟踪器参数
tracker_->set_max_time_lost(max_time_lost_->GetValueAs<int>());
tracker_->set_track_thresh(track_thresh_->GetValueAs<float>());
tracker_->set_match_thresh(match_thresh_->GetValueAs<float>());
// 转换输入格式并更新
std::vector<bytetrack_cpp::Object> objects;
for (auto& contour : contours) {
bytetrack_cpp::Object obj;
obj.rect = cv::boundingRect(contour.contour);
obj.prob = contour.area;
objects.push_back(obj);
}
auto tracks = tracker_->update(objects);
// 转换输出格式
std::vector<TrackResult> track_results;
for (auto& track : tracks) {
TrackResult result;
result.track_id = track.track_id;
result.rect = cv::Rect(track.tlwh[0], track.tlwh[1],
track.tlwh[2], track.tlwh[3]);
track_results.push_back(result);
}
tracks_->SetValue(std::move(track_results));
}
// 自定义预览面板:表格显示跟踪结果
void Preview() override {
auto value = tracks_->GetValueAs<std::vector<TrackResult>>();
if (ImGui::BeginTable("Tracks", 4,
ImGuiTableFlags_Borders | ImGuiTableFlags_ScrollY)) {
ImGui::TableSetupColumn("ID");
ImGui::TableSetupColumn("Label");
ImGui::TableSetupColumn("Position");
ImGui::TableSetupColumn("Size");
ImGui::TableHeadersRow();
for (auto& track : value) {
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0);
ImGui::Text("%d", track.track_id);
ImGui::TableSetColumnIndex(1);
ImGui::Text("%d", track.label);
ImGui::TableSetColumnIndex(2);
ImGui::Text("%d,%d", track.rect.x, track.rect.y);
ImGui::TableSetColumnIndex(3);
ImGui::Text("%d,%d", track.rect.width, track.rect.height);
}
ImGui::EndTable();
}
}
private:
std::shared_ptr<InputAttribute> contours_;
std::shared_ptr<IntInputAttribute> max_time_lost_;
std::shared_ptr<FloatInputAttribute> track_thresh_;
std::shared_ptr<FloatInputAttribute> match_thresh_;
std::shared_ptr<OutputAttribute> tracks_;
std::shared_ptr<bytetrack_cpp::BYTETracker> tracker_;
};
Preview() 方法让你可以在节点旁边显示一个自定义面板。这里用 ImGui 的表格展示跟踪 ID、位置等信息,比光看图像直观多了。
注册节点
写好节点后,需要在 Context 构造函数中注册:
// src/editor/context/context.cpp
#include "editor/node/my_threshold_node.h"
#include "editor/node/my_yolo_node.h"
Context::Context() {
// ... 其他注册
RegisterNode<MyThresholdNode>("myThreshold");
RegisterNode<MyYoloNode>("myYolo");
}
const Context::NodeNameMap& Context::GetNodeNames() {
static NodeNameMap map = {
{"Proc", {"myThreshold"}},
{"Detect", {"myYolo"}},
// ...
};
return map;
}
注册后,节点就会出现在编辑器左侧的节点列表中,分类由 GetNodeNames() 决定。
开发中的经验总结
1. 空值检查是第一要务
void MyNode::Exec() override {
auto input_image = input_->GetValueAs<cv::Mat>();
if (input_image.empty()) { // ← 永远第一个检查
output_->SetValue(cv::Mat());
return;
}
// ...
}
上游没连接、上游输出空图像、视频到最后一帧……都会导致空输入。不检查就崩。
2. 用移动语义传递 cv::Mat
output_->SetValue(std::move(result)); // 好,零拷贝
output_->SetValue(result); // 差,深拷贝
视频处理场景下,每帧都深拷贝图像,性能差距很大。
3. 模型在 Start() 里加载
不要在构造函数里加载模型(构造时用户还没选好路径),也不要在 Exec() 里做延迟初始化(第一帧会卡)。Start() 是正确的时机。
4. 参数有效性校验
auto size = ksize_->GetValueAs<cv::Size>();
if (size.width <= 0 || size.height <= 0) {
size = cv::Size(3, 3); // 回退到安全默认值
}
// 某些 OpenCV 函数要求奇数核
if (size.width % 2 == 0) size.width += 1;
5. 属性命名规范
我习惯用类型做前缀,下划线做后缀:
std::shared_ptr<InputAttribute> input_; // 输入
std::shared_ptr<CvMatOutputAttribute> output_; // 图像输出
std::shared_ptr<FloatInputAttribute> threshold_; // 浮点参数
cv::Ptr<cv::Tracker> tracker_; // 内部状态
bool initialized_ = false; // 标志位
从写节点中学到的
写节点的过程,本质上是把一个 OpenCV 函数调用"翻译"成一个可交互的组件。这个过程逼着我去理解每个参数的含义、边界条件和异常情况。
比如写 threshold 节点之前,我只知道 cv::threshold 有个 type 参数,但不知道 THRESH_OTSU 要和 THRESH_BINARY 组合使用。写枚举属性的时候查了文档才搞清楚。
再比如 ByteTrack 节点,跟踪器的参数(track_thresh、match_thresh 等)对结果影响很大。做成可调的属性后,调参体验比改代码好太多了。
这就是可视化工具的价值:降低试错成本,让算法调参从"编译-运行-看结果"变成"拖滑块-看结果"。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)