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 等)对结果影响很大。做成可调的属性后,调参体验比改代码好太多了。

这就是可视化工具的价值:降低试错成本,让算法调参从"编译-运行-看结果"变成"拖滑块-看结果"

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐