Line-It 节点编辑器

Line-It 节点编辑器

在wy的时候有推广过一个工具,是使用节点编辑器工具来完成一些程序化布景,称之为程序化工作流,不过那个工具主要是针对场编和美术策划相关的场景,而本人的工作是工具和自动化测试。当时就在想自动化测试应该是符合流水线一样执行的,能不能把两样东西结合起来,通过节点编辑器来完成自动化测试相关工作流,于是现在离职后就尝试实现一下

前言

节点编辑器已经是老东西了,网上各种实现五花八门,之前只接触过web前后端,本着学习和练手的目的,选择了客户端+qt(pyside,c++写不来)。由于本人没接触过qt,也是踩了很多坑。基于blenderfreak大佬的实现(https://gitlab.com/pavel.krupala/pyqt-node-editor)基础上进行开发

开贴记录一下,帖子里的内容并不能面面俱到,如果有不清楚的可以clone下面的仓库代码对着看。

image-20250319192905051

github仓库地址

https://github.com/wjwABCDEFG/LineIt

画布/图元

这种可以自由绘制画板的方式,写起来和以往的一般的控件不同,需要补充一点前置知识,基于图元绘制主要是三个类:

QGraphicsScene:背景画布格子线条
QGraphicsView:背景画布绘制模式(抗锯齿,全量更新)、鼠标键盘事件处理(滚轮放大缩小,中键画布拖拽)
QGraphicsItem:绘制正方形/圆形/曲线等基本元素,用来完成node/edge/socket等设计

  • 格子绘制

QGraphicsScene的drawBackground函数每次背景变动都会出发重新绘制,把当前的rect画满

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
def drawBackground(self, painter, rect):
"""每次背景变动都会触发"""
super().drawBackground(painter, rect)

# grid
left = int(math.floor(rect.left()))
right = int(math.ceil(rect.right()))
top = int(math.floor(rect.top()))
bottom = int(math.ceil(rect.bottom()))

first_left = left - (left % self.gridSize)
first_top = top - (top % self.gridSize)

# compute all lines to be drawn
lines_light, lines_dark = [], []
for x in range(first_left, right, self.gridSize):
if (x % (self.gridSize*self.gridSquares) != 0): lines_light.append(QLine(x, top, x, bottom))
else: lines_dark.append(QLine(x, top, x, bottom))

for y in range(first_top, bottom, self.gridSize):
if (y % (self.gridSize*self.gridSquares) != 0): lines_light.append(QLine(left, y, right, y))
else: lines_dark.append(QLine(left, y, right, y))

# draw the lines
painter.setPen(self._pen_light)
painter.drawLines(lines_light)

painter.setPen(self._pen_dark)
painter.drawLines(lines_dark)
  • 节点图形

元素比较简单一个矩形,左右两边的插槽(socket)构成的node

image-20250319194025184

QGraphicsItem的paint函数中绘制节点,分为上面的title和content两个矩形,并加上了圆角,这部分都是固定写法,需要注意的就是content的height实际上是从[title_height, height-title_height]

image-20250319214816012

吐槽,虽然是固定写法,但是qt这个写起来也太麻烦了

ui和逻辑的互操架构

blenderfreak大佬的实现中给了一个设计图,这也是我看了几个节点编辑器后觉得这个最好的原因,总体就是这几个部分:

Scene的QWidget类保存了scene的QGraphicsView对象

Node的QWidget类保存了node和content的QGraphicsItem对象

Edge的QWidget类保存了edge的QGraphicsItem对象

Socket的QWidget类保存了socket的QGraphicsItem对象

同时,又在Graph的构造中传入了Widget对象,互相保存,在下面的节点设计中展示了具体的伪代码,总之有点类似安卓的context

image-20250319215827494

ui部分就到这里,qt的很多代码看起来很繁杂,但都是固定写法,比如图元绘制的addRoundedRect/addRect/setPen/setBrush/drawPath,但归根到底都是些api的调用

对于节点编辑器,后续的socket逻辑才是大头

节点的设计

基本结构

ui和逻辑分离,ui部分使用qt的QGraphicsItem绘制,逻辑在Node类中

为了方便互操,ui对象作为Node的成员,同时构造的时候会将本对象self传入ui对象中作为成员(这个操作类似android经常会将activity的this传入fragment中, 不过安卓中形参称之为context上下文,这里没有过多专业的名词,不过本质上是一样的)

1
2
3
4
5
6
7
8
9
class QDMGraphicsNode(QGraphicsItem):
def __init__(self, node: 'Node'):
super().__init__(parent)
self.node = node

class Node:
def __init__(self):
super().__init__()
self.grNode = QDMGraphicsNode(self)

和socket/edge关联

socket就是node左右两边的连接点,node里面保存了inputs和outputs节点,保存了所有的socket,这是整个编辑器最复杂的地方,因为socket实际上是连接node和edge的媒介,设计时要考虑的东西很多,但我并不想在这里单独开一章,原来大佬的设计很精彩,但我这里的重点是整体的功能

socket的属性包括:

  • node:所在node的引用,和之前说的一样,类似context互操
  • edges: 连接的所有edge,注意,input socket只能连接一根线,但是output socket可以连接多根线
  • index:第几个socket,同一侧可能有多个,用于绘制时计算位置和序列化时使用
  • position:即使在同一侧,也可以选择左上中下,右上中下几种,也是用于绘制
  • socket_type:这部分我认为原本的设计存在不足,代码里写的Constant defining type(color) of this socket,也就是说只是一个color,我估计原本作者的想法应该是像unity shader或者ue蓝图一样,根据输出的值类型变化颜色,让用户知道什么output能连接什么input,不允许乱连接,但由于作者最后是做了一个计算器,所以都是int没有细化这部分
  • is_input:通过这个来区分是输入还是输出

edge的属性包括:

  • start_socket和end_socket
  • edge_type:贝塞尔曲线/直线

当一个节点要找前面/后面节点的时候,实际上的链路为:node -> (for)output_socket -> (for)edge -> end_socket -> next_node

动态注册

为了方便用户添加自定义节点,需要动态注册的方式。后续如使用pyinstaller打包时,也可以通过配置.spec文件跳过对nodes文件夹下的打包,以方便用户自定义和参考原有node

  • 通过@register_node(“DIV”)装饰器把这个class添加到字典CALC_NODES = {}中保存动态,所有需要添加的节点都可以通过这个装饰器添加进节点编辑器中,方便用户自定义和扩展节点

image-20250319185643834

  • 使用__all__注册整个包下的py文件,后续打包时可以屏蔽这个文件夹,用户便可以在这个文件夹下自由的编写自定义节点

image-20241227150016692

计算节点

  • 如四则运算节点,父节点CalcNode抽象出一个evalImplementation/evalOperation方法,子节点类都需要实现

image-20240727165145550

  • 数据存储:每个节点有一个value字段用于存储,当想要计算时,通过getInput(idx)方法从指定的输入端口(inputs[idx])找到对应的上游节点拿其value,完成计算后也要将结果放置在当前节点的value中

  • 向后计算:向后计算时,对着当前节点点击eval,会调用eval后,调用evalChildren遍历当前节点所有output,找到其下一层子节点并调用其eval方法,需要注意的是整个计算顺序应该是dfs

1
2
3
4
5
6
7
def evalChildren(self):
"""
使用这个完成计算,每次计算完,找到下一个节点继续计算
每个eval里面要实现从input端口的上一个连接节点拿数据,计算,把结果放到value中
"""
for node in self.getChildrenNodes():
node.eval()
  • 向前计算:向前计算时,比如output,对着output点击eval,往前找所有的input,并调用其eval函数,可以自动向前完成计算,其过程也是dfs
1
2
3
4
def evalImplementation(self):
input_node = self.getInput(0) # 如果input端口不止一个也可以遍历inputs
val = input_node.eval()
return val

序列化/反序列化

要实现graph的持久化保存,或数据传输,甚至是redo/undo功能,都必须完成序列化和反序列化工作,通过字符串完整表达graph中的数据

serialize

需要序列化的东西包括如下几个部分:

  • 场景:id、画布的大小

  • 节点(列表):节点id、标题、位置、inputs输入点列表(id、idx、位置、类型)、outputs输出点列表(id、idx、位置、类型)、内容

  • 边(列表):边的id、起点和终点的节点id

因此整个序列化和反序列化过程是一个自顶向下的结构,完成如上所述的整个场景的序列化,实际上是分别对node和edge进行序列化,最终返回一个dict方便后续的json直接保存成字符串:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 场景序列化伪代码
def serialize(self):
nodes, edges = [], []
for node in self.nodes: nodes.append(node.serialize()) # node序列化
for edge in self.edges: edges.append(edge.serialize()) # edge序列化

return {
'id', self.id,
'scene_width', self.scene_width,
'scene_height', self.scene_height,
'nodes', nodes,
'edges', edges,
}

node和edge的serialize实现也是按上面我们所说的保存相应的信息到dict

当然,上述伪代码写的是dict,最终的实现是OrderedDict,保证了每次保存数据key的顺序是一样的

deserialize

反序列化即是根据dict信息构造出整个画布,deserialize和serialize结构类似,也是分别对node和edge进行反序列化,反序列化的过程实际上就是根据信息创建node和edge对象的过程。但是需要注意以下关键点

  • id的存储,按理来说,即使没有id也可以完全复刻一个长得一模一样的场景,为什么还要记录id呢?因为创建出来的node只是位置和内容相同并非原来的node(内存地址不同),而边的序列化信息又需要记录起点和终点是哪个node的哪个socket,我们没有办法在同样的内存地址中创建对象,因此这个id只能当成成员变量记录起来
1
2
3
4
5
6
7
8
# deserialize伪代码
def Node:
def __init__(self):
self.id = id(self)

def deserialize(self, data: dict, hashmap: dict={}) -> bool:
if restore_id: self.id = data['id']
# ...

这样一来,虽然新旧节点内存地址不一样,但self.id是一样的,在edge反序列化的时候就可以通过起点终点id来找到是哪个node的哪个socket了

  • 注意到形参这里有个hashmap,算是一个优化点,node和socket反序列化时就加入{id: obj}进hashmap,这样edge序列化的时候就可以直接通过id寻找socket了不需要遍历查找

undo/redo功能

这里的一个误区在于:我们首先会想到一个操作栈history_stack,把所有的操作都入栈,看起来undo就是弹出过程,redo就是栈恢复的过程。但这样做从实现的角度是非常复杂的

实际上,设计应该如下:

  • 我们并不会用到栈stack这种存储结构,因为在多次undo后每次弹出的内容还不能抛弃,因为可能又多次redo,因此如果使用stack实现,需要用另一个栈来存储,因此我们history_stack实际上采用的是数组队列+下标指针
  • 存储的不应该是操作,因为操作需要正向和反向,比如undo删除对应redo创建,由于操作非常多,这是不合理的。因此实际上history_stack是存储整个面板的serialize数据,整个过程为:“变动” -> store(serialized_data) -> undo/redo -> restore(serialized_data),这样undo和redo实际上都是重绘整个画布,只是在移动cur_idx指针,逻辑统一实现简单
  • “变动”包括:节点(新建/移动/删除),边(新建/删除),选中和取消选中
  • 刚开始创建/打开节点文件的时候要记录一次
  • 当undo几次后,如果不是redo而是storeHistory插入,那么就会丢弃原本可以redo的部分,就像丫字形,视为开始新的一条岔路

剪贴板功能

利用QApplication.instance().clip实现,在复制(ctrl+c)和剪贴(ctrl+x)的时候序列化并存入剪贴板,粘贴(ctrl+v)的时候反序列化

注意:

  • 粘贴的时候,需要给node、socket和edge添加一个新的id,否则id重复。由于序列化/反序列化所有节点都存储于一个hashmap中,会导致key重复

  • 剪切(删除)节点后,如果剪贴板的边有未连接的节点,需要剔除,如果view中剩余的边有未连接的节点,也需要剔除

MDI窗口

Multiple Document Interface多文档界面,即一个窗口下通过tab分割多个子窗口,文档型工具通常不会只打开一个文件,如下所示

image-20240531014758751

可以极大的提高生产力和便利性,据说是由微软提出的用于excel多表编辑,但不知道为什么后来的excel弃用了,多表采用多窗口

mdi窗口现在有大量通用的参考,QT也有预制的实现QMdiArea,关键点:

  • 原来QMainWindow.CentralWidget = QWidget对象,现在中间添加了一层QMdiArea,变为QMainWindow.CentralWidget = QMdiArea对象,然后通过mdiArea.addSubWindow(QWidget)添加每个tab项里的QWidget对象
  • 几个api并不复杂,关键点在于每次操作里面的widget的时候,记得先通过mdiArea.activeSubWindow().widget()来获取当前的widget
  • 这也侧面说明了为什么history_stack要放在Widget而不是window中的原因,因为每个graph的history_stack是独立的,同理保存等操作也是独立的,记得先获取当前激活的窗口

Details面板

某一个节点可能有需要配置的东西,不好直接在一个节点的content里展示,显得非常凌乱,所以在右侧加入了details面板,这样方便用户配置自己的属性

image-20250319225153988

考虑到用户可能会自定义节点,因此在node_base中也暴露了detailsInfo:属性, 类型为List[QWidget],自定义节点时,可以通过往该属性中不断append widget,即可在Details面板中看到需要的属性

需要注意的是,这个面板里的参数如果需要保存,应该加入到序列化的value中

开关

在自动化测试中,根据以往的经验,有几次可能要执行某一段代码,而有几次又要不执行某一段代码。因此为每个节点设计了开关,如果关闭,则此次执行跳过该节点

eval执行流程设计

考虑自动化需求,重新实现了计算流程,a -> b -> c -> d,有可能需要先执行到c出错了,此时希望不重复执行ab,而是从c开始继续执行,由于前面已经实现了前向计算和后向计算的基本操作,这里考虑如下设计方案

方案一(向后执行):

将c mark dirty,会把后续的子节点都mark dirty,对着c或者b进行eval向后执行

方案二(向前执行):

将c mark dirty,会把后续的子节点都mark dirty,对着d进行eval向前执行,此方案会导致一个问题,如果c出现了分叉c->d同时还有c->e,没法执行到c->e

方案三(向前执行修正):

将c mark dirty,会把后续的子节点都mark dirty,对着d进行eval,不要向前执行而是改为往前找到已经ok的节点b,再从该节点开始向后执行

暂时是方案二啊,我意识到有问题,后面再改

方案一和方案三相比,方案三更加符合正常人的操作习惯,因为连接完节点后习惯于从最后的节点开始点击执行

Store节点

在之前使用节点编辑器的时候就发现了一个问题,对于正常编写程序,逻辑本就不是像流水线一样线性的,难免存在如下代码:

1
2
3
4
5
6
7
8
9
a = 1

xxx 中间一大段

b = 2

xxx 中间一大段

c = a + b

如果a b c分别在不同的节点,a -> b ->c

那么c想使用a的时候,就得把a先传给b,再把b传给c

在以前工作时使用的节点编辑器中,爷爷节点的数据需要一路传下来,链路上保存的数据就越来越多,而且每次都得往下传!!(即使这个节点编辑器的数据是不在链路上而在节点中,但依然存在这个问题),虽然自动化测试更加接近流水线一点,但使用历史数据问题肯定是在所难免的,因此要解决这个设计缺陷

Store节点,本质上是往一个空的global_data存东西,这里用的是模块,有点数据总线的味道~

由于output socket本身就可以连接多根线,所以不用担心从哪个socket出来的问题,在LineIt中,本身output大概率就只有一个socket

接口类型颜色

也许在上一个节点value的类型转换成颜色值,下一个的getInput里面验证,不一样类型的就不允许连接

TODO,暂时搁置,很多连接点其实是没有类型传输给下个节点的,不确定这种功能是否会导致操作不便,需要再仔细考量

动态增加ouput数量

TODO,在LineIt中,本身output大概率就只有一个socket,所以可能没多大用处,暂时搁置

节点列表

既然是做自动化了,那就尝试性的把一些功能封装成节点吧

移动设备操作类

这里要注意尽量兼容多平台,主流就是android/iOS,android使用adb,iOS使用tidevice/wda,相当于做了之前airtest的一些工作,但是这样更加原生一点,也避免引入了很多不必要的东西

Nodes 作用 设计
设备列表 显示通过usb连接的android和iOS设备 开两个线程,分别通过adb和tidevice检测设备连接情况,然后emit到该node更新信息即可
打开应用 打开app /
截屏 截屏 /
性能数据 监测性能数据 单独开个widget对每台已连接设备展示性能数据,2s一次即可
安装应用 / /
TODO模拟操作 单机/双击/滑动? 暂时不做,如果做了感觉有点像appium/airtest了?或者后面直接集成呢?

https://blog.csdn.net/qq_27672101/article/details/143996095

https://zhuanlan.zhihu.com/p/3422441811

文件类

Nodes 作用 设计
文件列表 用于配合下面的节点批量操作
重命名 搭配文件列表进行批量重命名 windows的会变成a_1.png,a_1_1.png,不用我多说了吧
TODO复制 暂时不做,意义不大,如果后续有生成文件时再补充
TODO移动 暂时不做,意义不大,如果后续有生成文件时再补充

图像类

Nodes 作用 设计
TODO,后续搞点超分辨率或者stable diffusion相关的?
TODO,也许针对图片后期处理这部分有得做,滤镜/对比度/高光/曝光色调这些

工具类

Nodes 作用 设计
等待 sleep(s) 有些时候打开应用这种操作,没有回调,就没办法知道打开没有的,sleep个几秒看看
store 全局存储节点 用于非流水线式的变量取值
python 有些人就说:哎呀我就不想打开破编辑器写自定义节点,我就想简单写两行验证一下 行,双击node还可以打开vscode

可能存在的问题以及后续的规划

节点通用性问题

要思考封装的节点是否具有通用性,否则容易变成这一套节点只能用于这一套graph。为了避免出现这种问题,封装节点可以思考如下几点:

  • 该节点是否能想到第二种连接方式搭配使用,如果是,则存在合理性
  • 是否以“功能”/“操作”为单位,如果是,则存在合理性
  • 本次编写的所有节点链路是否需要合并成一个节点,如果是,则不合理

image-20250324023013889

避免在eval相关的函数中操作界面

并行化改造后,eval可能由于父节点不在同一线程,此时操作界面可能会导致界面无法更新或者无法显示的问题,并且这本来也是不好的习惯。如果要在eval中修改界面,请通过信号槽机制来修改。这点非常重要

并行化改造存在的问题

关于并行化改造,目前还存在问题,本来应该使用多进程multiprocess操作安卓设备的,在python中,创建多进程在linux下是使用fork,而在windows中使用的是pickle进行序列化+反序列化,但是pyqt/pyside中QObject实际上是c++对象,于是无法pickle会报错

这算是pyside的坑,暂时没有解决

pyside的坑(知乎),答主介绍了一种重写__repr__魔法方法使pickle序列化的解决方案,太深

for循环问题

这种情况在纯coding中肯定是存在的,而在节点编辑器中处理起来又很麻烦不优雅,目前市面上的节点编辑器也没有找到合适的方案,比如comfyui是单张图片的工作流而不是面向“批量化”处理,ShaderGraph是面对gpu的并行化操作本身没有for逻辑,UE蓝图倒是有WhileLoop/ForLoop节点,可以参考,但ue蓝图是面向程序的,参数给到了节点上,而LineIt是面向工作流的,需要重新设计

image-20250324150541420

image-20250324150555401

假设有如下操作

1
2
3
4
for item in list:
doA
doB
doC

在没有for循环节点的情况下,变成了

1
2
3
4
5
6
7
8
9
10
11
//node1
for item in list:
doA

//node2
for item in list:
doB

//node3
for item in list:
doC

复杂度也会由O(n)变成O(3n)

参数节点化问题

后续可以做一个功能,允许把detailsInfo的参数变成输入节点,这样可能更加灵活一些

暂定的设计:右键节点->暴露参数->选择detailsInfo中的参数列表->扩展input节点

如果做这个的话,每个socket可能还要带一个label标签注明这个socket的作用

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×