检测流程改一次就要重新编译?流程节点加数据端口,配置改改就行
之前做一个端子检测的项目,检测流程是:拍照→灰度化→滤波→阈值分割→轮廓分析→判定。六个步骤写死在代码里,步骤之间有依赖关系,每一步的输出是下一步的输入。
项目后期客户提了需求: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. 节点抽象——每个检测步骤是一个独立节点,有输入输出端口 2. 数据端口——节点之间通过端口连接,上游写下游读 3. 配置驱动——JSON文件定义节点列表和连接关系,换流程改配置
那个端子检测项目,改成节点+端口模型之后,客户再提流程变更,改JSON就行,不用重新编译部署。加节点也简单——实现一个INode,注册到工厂,配置文件里加上就行。
小提示:第一次设计节点,先把常用的检测步骤列出来。灰度化、滤波、阈值、形态学、轮廓、匹配、判定,每个步骤一个节点。节点粒度不要太细(比如“读像素”这种就不该是节点),也不要太粗(比如“整个预处理”一个大节点就失去了灵活性)。一个节点对应一个可独立配置、可独立替换的操作。