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

更多推荐