×

C#实现视觉任务编排:使用流程节点与数据端口实现可配置检测流程

独孤求败 独孤求败 发表于2026-10-13 14:33:30 浏览14 评论0

抢沙发发表评论

检测流程改一次就要重新编译?流程节点加数据端口,配置改改就行

之前做一个端子检测的项目,检测流程是:拍照→灰度化→滤波→阈值分割→轮廓分析→判定。六个步骤写死在代码里,步骤之间有依赖关系,每一步的输出是下一步的输入。

项目后期客户提了需求:A型号工件要加一个边缘检测步骤,B型号工件要跳过阈值分割直接用边缘检测,C型号工件要在轮廓分析之后加一个面积过滤。每个型号的流程不一样,改一次就要改代码、重新编译、重新部署。

后来把流程拆成了节点+端口的模型。每个检测步骤是一个节点,节点有输入端口和输出端口,节点之间通过端口连接。配置文件定义节点列表和连接关系。换型号改配置文件就行,代码不用动。

一、问题出在哪

写死的流程长这样:

// ❌ 流程写死在代码里
public DetectionResult Detect(Mat image)
{
    // 步骤1:灰度化
    Mat gray = new Mat();
    Cv2.CvtColor(image, gray, ColorConversionCodes.BGR2GRAY);

    // 步骤2:滤波
    Mat blurred = new Mat();
    Cv2.GaussianBlur(gray, blurred, new Size(5, 5), 1.5);

    // 步骤3:阈值分割
    Mat binary = new Mat();
    Cv2.Threshold(blurred, binary, 0, 255, 
        ThresholdTypes.Binary | ThresholdTypes.Otsu);

    // 步骤4:轮廓分析
    Point[][] contours;
    Cv2.FindContours(binary, out contours, out _, ...);

    // 步骤5:判定
    bool isPass = contours.Length < 10;

    returnnew DetectionResult { IsPass = isPass };
}

每个型号的流程不同,就得写多个方法,或者加if-else判断型号。步骤越加越多,if-else越来越长,改一处代码要测所有型号。

二、节点和端口的设计

核心抽象就两个:

节点(INode) :一个处理单元。有输入端口和输出端口。执行的时候从输入端口取数据,处理完往输出端口写数据。

端口(DataPort) :节点之间的数据通道。有名字、有数据类型。一个节点的输出端口连到另一个节点的输入端口。

[图像采集] --image--> [灰度化] --gray--> [滤波] --blurred--> [阈值分割] --binary--> [轮廓分析] --contours--> [判定] --result--> [输出]

每个节点只管自己的输入输出,不关心上游是谁、下游是谁。

三、核心接口

using System;
using System.Collections.Generic;
using OpenCvSharp;

/// <summary>
/// 数据端口
/// </summary>
publicclassDataPort
{
    publicstring Name { get; set; }
    public Type DataType { get; set; }
    publicobject Data { get; set; }
    publicbool HasData => Data != null;

    publicDataPort(string name, Type dataType)
    {
        Name = name;
        DataType = dataType;
    }
}

/// <summary>
/// 流程节点接口
/// </summary>
publicinterfaceINode
{
    string NodeId { get; }
    string NodeType { get; }
    
    // 输入端口
    Dictionary<string, DataPort> InputPorts { get; }
    
    // 输出端口
    Dictionary<string, DataPort> OutputPorts { get; }

    // 初始化(从配置读参数)
    voidInitialize(Dictionary<string, object> parameters);

    // 执行
    boolExecute();
}

/// <summary>
/// 节点基类
/// </summary>
publicabstractclassNodeBase : INode
{
    publicstring NodeId { get; protectedset; }
    publicabstractstring NodeType { get; }
    public Dictionary<string, DataPort> InputPorts { get; } = new();
    public Dictionary<string, DataPort> OutputPorts { get; } = new();

    protected Dictionary<string, object> _parameters = new();

    publicvirtualvoidInitialize(Dictionary<string, object> parameters)
    {
        _parameters = parameters ?? new Dictionary<string, object>();
    }

    publicabstractboolExecute();

    /// <summary>
    /// 添加输入端口
    /// </summary>
    protectedvoidAddInputPort(string name, Type type)
    {
        InputPorts[name] = new DataPort(name, type);
    }

    /// <summary>
    /// 添加输出端口
    /// </summary>
    protectedvoidAddOutputPort(string name, Type type)
    {
        OutputPorts[name] = new DataPort(name, type);
    }

    /// <summary>
    /// 从输入端口取数据
    /// </summary>
    protected T GetInput<T>(string portName)
    {
        if (InputPorts.TryGetValue(portName, outvar port) && port.HasData)
            return (T)port.Data;
        returndefault(T);
    }

    /// <summary>
    /// 往输出端口写数据
    /// </summary>
    protectedvoidSetOutput(string portName, object data)
    {
        if (OutputPorts.TryGetValue(portName, outvar port))
            port.Data = data;
    }

    /// <summary>
    /// 获取参数
    /// </summary>
    protected T GetParam<T>(string name, T defaultValue)
    {
        if (_parameters.TryGetValue(name, outvarvalue))
        {
            if (valueis T typed) return typed;
            try { return (T)Convert.ChangeType(value, typeof(T)); }
            catch { return defaultValue; }
        }
        return defaultValue;
    }
}

四、几个常用的节点实现

/// <summary>
/// 图像采集节点
/// </summary>
publicclassCaptureNode : NodeBase
{
    publicoverridestring NodeType => "Capture";

    publicCaptureNode()
    {
        AddOutputPort("image", typeof(Mat));
    }

    publicoverrideboolExecute()
    {
        // 从相机采集一帧(实际项目中接相机SDK)
        Mat image = CaptureFromCamera();
        if (image == null || image.Empty())
            returnfalse;

        SetOutput("image", image);
        returntrue;
    }

    private Mat CaptureFromCamera()
    {
        // 实际项目替换为相机取图
        returnnew Mat(480, 640, MatType.CV_8UC3, new Scalar(128, 128, 128));
    }
}

/// <summary>
/// 灰度化节点
/// </summary>
publicclassGrayscaleNode : NodeBase
{
    publicoverridestring NodeType => "Grayscale";

    publicGrayscaleNode()
    {
        AddInputPort("image", typeof(Mat));
        AddOutputPort("gray", typeof(Mat));
    }

    publicoverrideboolExecute()
    {
        Mat image = GetInput<Mat>("image");
        if (image == null) returnfalse;

        Mat gray = new Mat();
        if (image.Channels() == 3)
            Cv2.CvtColor(image, gray, ColorConversionCodes.BGR2GRAY);
        else
            image.CopyTo(gray);

        SetOutput("gray", gray);
        returntrue;
    }
}

/// <summary>
/// 高斯滤波节点
/// </summary>
publicclassGaussianBlurNode : NodeBase
{
    publicoverridestring NodeType => "GaussianBlur";

    publicGaussianBlurNode()
    {
        AddInputPort("image", typeof(Mat));
        AddOutputPort("blurred", typeof(Mat));
    }

    publicoverrideboolExecute()
    {
        Mat image = GetInput<Mat>("image");
        if (image == null) returnfalse;

        int kernelSize = GetParam("kernelSize", 5);
        double sigma = GetParam("sigma", 1.5);

        Mat blurred = new Mat();
        Cv2.GaussianBlur(image, blurred, 
            new Size(kernelSize, kernelSize), sigma);

        SetOutput("blurred", blurred);
        returntrue;
    }
}

/// <summary>
/// 阈值分割节点
/// </summary>
publicclassThresholdNode : NodeBase
{
    publicoverridestring NodeType => "Threshold";

    publicThresholdNode()
    {
        AddInputPort("image", typeof(Mat));
        AddOutputPort("binary", typeof(Mat));
    }

    publicoverrideboolExecute()
    {
        Mat image = GetInput<Mat>("image");
        if (image == null) returnfalse;

        string method = GetParam("method", "otsu");
        Mat binary = new Mat();

        if (method == "otsu")
        {
            Cv2.Threshold(image, binary, 0, 255,
                ThresholdTypes.Binary | ThresholdTypes.Otsu);
        }
        else
        {
            double thresh = GetParam("threshold", 128.0);
            Cv2.Threshold(image, binary, thresh, 255,
                ThresholdTypes.Binary);
        }

        SetOutput("binary", binary);
        returntrue;
    }
}

/// <summary>
/// 轮廓分析节点
/// </summary>
publicclassContourAnalysisNode : NodeBase
{
    publicoverridestring NodeType => "ContourAnalysis";

    publicContourAnalysisNode()
    {
        AddInputPort("binary", typeof(Mat));
        AddOutputPort("contours", typeof(Point[][]));
        AddOutputPort("count", typeof(int));
    }

    publicoverrideboolExecute()
    {
        Mat binary = GetInput<Mat>("binary");
        if (binary == null) returnfalse;

        Point[][] contours;
        HierarchyIndex[] hierarchy;
        Cv2.FindContours(binary, out contours, out hierarchy,
            RetrievalModes.External, ContourApproximationModes.ApproxSimple);

        double minArea = GetParam("minArea", 100.0);
        var filtered = contours.Where(c => Cv2.ContourArea(c) >= minArea).ToArray();

        SetOutput("contours", filtered);
        SetOutput("count", filtered.Length);
        returntrue;
    }
}

/// <summary>
/// 判定节点
/// </summary>
publicclassJudgmentNode : NodeBase
{
    publicoverridestring NodeType => "Judgment";

    publicJudgmentNode()
    {
        AddInputPort("count", typeof(int));
        AddOutputPort("result", typeof(bool));
    }

    publicoverrideboolExecute()
    {
        int count = GetInput<int>("count");
        int maxCount = GetParam("maxCount", 10);

        bool isPass = count <= maxCount;
        SetOutput("result", isPass);
        returntrue;
    }
}

五、流程引擎

流程引擎负责:根据配置创建节点、连接端口、按拓扑顺序执行。

/// <summary>
/// 流程引擎
/// </summary>
publicclassFlowEngine
{
    privatereadonly Dictionary<string, INode> _nodes = new();
    privatereadonly List<(string fromNode, string fromPort, 
        string toNode, string toPort)> _connections = new();
    privatereadonly List<string> _executionOrder = new();

    /// <summary>
    /// 从配置构建流程
    /// </summary>
    publicvoidBuildFromConfig(FlowConfig config)
    {
        _nodes.Clear();
        _connections.Clear();
        _executionOrder.Clear();

        // 1. 创建所有节点
        foreach (var nodeConfig in config.Nodes)
        {
            var node = NodeFactory.Create(nodeConfig.Type);
            if (node == null)
                thrownew Exception($"未知节点类型: {nodeConfig.Type}");

            // 设置NodeId(通过反射,实际项目中可以改成构造函数传入)
            var idProp = node.GetType().GetProperty("NodeId");
            // NodeBase的NodeId有protected setter,需要特殊处理
            // 简化:用包装类或者工厂方法设置

            node.Initialize(nodeConfig.Parameters);
            _nodes[nodeConfig.Id] = node;
        }

        // 2. 建立连接
        foreach (var conn in config.Connections)
        {
            _connections.Add((conn.FromNode, conn.FromPort, 
                conn.ToNode, conn.ToPort));
        }

        // 3. 拓扑排序确定执行顺序
        _executionOrder = TopologicalSort();
    }

    /// <summary>
    /// 执行流程
    /// </summary>
    publicboolExecute()
    {
        foreach (var nodeId in _executionOrder)
        {
            var node = _nodes[nodeId];

            // 从上游节点的输出端口取数据,填入当前节点的输入端口
            foreach (var conn in _connections.Where(c => c.toNode == nodeId))
            {
                var fromNode = _nodes[conn.fromNode];
                if (fromNode.OutputPorts.TryGetValue(conn.fromPort, outvar fromPort))
                {
                    if (node.InputPorts.TryGetValue(conn.toPort, outvar toPort))
                    {
                        toPort.Data = fromPort.Data;
                    }
                }
            }

            // 执行节点
            if (!node.Execute())
            {
                Console.WriteLine($"节点 {nodeId} 执行失败");
                returnfalse;
            }
        }

        returntrue;
    }

    /// <summary>
    /// 拓扑排序
    /// </summary>
    private List<string> TopologicalSort()
    {
        var inDegree = new Dictionary<string, int>();
        var adjacency = new Dictionary<string, List<string>>();

        foreach (var nodeId in _nodes.Keys)
        {
            inDegree[nodeId] = 0;
            adjacency[nodeId] = new List<string>();
        }

        foreach (var conn in _connections)
        {
            adjacency[conn.fromNode].Add(conn.toNode);
            inDegree[conn.toNode]++;
        }

        var queue = new Queue<string>();
        foreach (var kv in inDegree)
        {
            if (kv.Value == 0) queue.Enqueue(kv.Key);
        }

        var result = new List<string>();
        while (queue.Count > 0)
        {
            var node = queue.Dequeue();
            result.Add(node);

            foreach (var next in adjacency[node])
            {
                inDegree[next]--;
                if (inDegree[next] == 0) queue.Enqueue(next);
            }
        }

        if (result.Count != _nodes.Count)
            thrownew Exception("流程存在环");

        return result;
    }

    /// <summary>
    /// 获取节点输出
    /// </summary>
    public T GetOutput<T>(string nodeId, string portName)
    {
        if (_nodes.TryGetValue(nodeId, outvar node))
        {
            if (node.OutputPorts.TryGetValue(portName, outvar port) 
                && port.HasData)
            {
                return (T)port.Data;
            }
        }
        returndefault(T);
    }
}

六、配置文件和节点工厂

/// <summary>
/// 流程配置
/// </summary>
publicclassFlowConfig
{
    publicstring FlowName { get; set; }
    public List<NodeConfig> Nodes { get; set; } = new();
    public List<ConnectionConfig> Connections { get; set; } = new();
}

publicclassNodeConfig
{
    publicstring Id { get; set; }
    publicstring Type { get; set; }
    public Dictionary<string, object> Parameters { get; set; } = new();
}

publicclassConnectionConfig
{
    publicstring FromNode { get; set; }
    publicstring FromPort { get; set; }
    publicstring ToNode { get; set; }
    publicstring ToPort { get; set; }
}

/// <summary>
/// 节点工厂
/// </summary>
publicstaticclassNodeFactory
{
    privatestaticreadonly Dictionary<string, Func<INode>> _creators = new();

    staticNodeFactory()
    {
        Register("Capture", () => new CaptureNode());
        Register("Grayscale", () => new GrayscaleNode());
        Register("GaussianBlur", () => new GaussianBlurNode());
        Register("Threshold", () => new ThresholdNode());
        Register("ContourAnalysis", () => new ContourAnalysisNode());
        Register("Judgment", () => new JudgmentNode());
    }

    publicstaticvoidRegister(string type, Func<INode> creator)
    {
        _creators[type] = creator;
    }

    publicstatic INode Create(string type)
    {
        if (_creators.TryGetValue(type, outvar creator))
            return creator();
        returnnull;
    }
}

JSON配置文件示例:

{
  "flowName":"端子检测流程",
"nodes":[
    {"id":"capture","type":"Capture","parameters":{}},
    {"id":"gray","type":"Grayscale","parameters":{}},
    {"id":"blur","type":"GaussianBlur","parameters":{"kernelSize":5,"sigma":1.5}},
    {"id":"threshold","type":"Threshold","parameters":{"method":"otsu"}},
    {"id":"contour","type":"ContourAnalysis","parameters":{"minArea":100}},
    {"id":"judge","type":"Judgment","parameters":{"maxCount":10}}
],
"connections":[
    {"fromNode":"capture","fromPort":"image","toNode":"gray","toPort":"image"},
    {"fromNode":"gray","fromPort":"gray","toNode":"blur","toPort":"image"},
    {"fromNode":"blur","fromPort":"blurred","toNode":"threshold","toPort":"image"},
    {"fromNode":"threshold","fromPort":"binary","toNode":"contour","toPort":"binary"},
    {"fromNode":"contour","fromPort":"count","toNode":"judge","toPort":"count"}
]
}

七、使用方式

class Program
{
    staticvoidMain(string[] args)
    {
        // 加载配置
        string json = File.ReadAllText("flow_config.json");
        var config = JsonSerializer.Deserialize<FlowConfig>(json);

        // 构建流程
        var engine = new FlowEngine();
        engine.BuildFromConfig(config);

        // 执行
        if (engine.Execute())
        {
            bool result = engine.GetOutput<bool>("judge", "result");
            Console.WriteLine($"检测结果: {(result ? "OK" : "NG")}");
        }
    }
}

换型号的时候,改JSON配置文件就行。A型号加一个边缘检测节点,B型号跳过阈值分割节点,代码完全不用动。

八、几个容易踩的坑

坑一:端口数据类型不匹配

上游输出的是Mat,下游输入端口声明的是Point[][],取数据的时候会抛InvalidCastException。连接的时候校验端口类型,类型不匹配直接报错。

坑二:拓扑排序检测到环

A的输出连到B,B的输出又连回A,形成死循环。拓扑排序能检测出来,构建流程的时候抛异常,早点暴露问题。

坑三:节点执行失败没处理

某个节点返回false,后面的节点不该继续执行。流程引擎里检查每个节点的返回值,失败就中断。

坑四:Mat资源没释放

每个节点创建的Mat,用完之后要释放。流程执行完之后,遍历所有节点的输出端口,释放IDisposable对象。

坑五:配置文件路径硬编码

flow_config.json的路径不能写死在代码里。用程序启动目录的相对路径,或者从配置中心读取。

九、总结

可配置检测流程的核心就三件事:

  1. 1. 节点抽象——每个检测步骤是一个独立节点,有输入输出端口
  2. 2. 数据端口——节点之间通过端口连接,上游写下游读
  3. 3. 配置驱动——JSON文件定义节点列表和连接关系,换流程改配置

那个端子检测项目,改成节点+端口模型之后,客户再提流程变更,改JSON就行,不用重新编译部署。加节点也简单——实现一个INode,注册到工厂,配置文件里加上就行。

小提示:第一次设计节点,先把常用的检测步骤列出来。灰度化、滤波、阈值、形态学、轮廓、匹配、判定,每个步骤一个节点。节点粒度不要太细(比如“读像素”这种就不该是节点),也不要太粗(比如“整个预处理”一个大节点就失去了灵活性)。一个节点对应一个可独立配置、可独立替换的操作。


群贤毕至

访客