【工创赛2025-智能物流搬运塔吊方案视觉开源(2分15秒)】西安理工大学工程训练中心

【工创赛2025-智能物流搬运塔吊方案视觉开源(2分15秒)】西安理工大学工程训练中心

一、前言

        本文也是我的第一篇ZEEKLOG博客,主要内容是记录一下2025年工训赛的参赛过程,讲解一下与louisaerdusai学长一起开发的智能物流视觉方案。主要内容为:实现函数、串口与下位机的通讯和整个实现流程,希望我们的经验能够帮助大家。

        本文为视觉算法开源,其他部分开源请移步:【工创赛2025-塔吊结构方案开源(2分15秒)】西安理工大学工程训练中心-ZEEKLOG博客

二、本届视觉设计由来

        我在今年校赛阶段参加的是智能救援赛道,由于我们机械设计的过于复杂和一些其他原因,机械结构的反复修改,最终没有尽快实现视觉与机械结构联调,导致我们在校赛就遗憾出局。在校赛遗憾结束后,我有幸加入了学长的队伍,在重新了解了物流搬运的视觉流程后,发现使用Jetson Nano运行OpenCV算法算是更加灵活的选择。但是在省赛是我也发现很多队伍采用的OpenMV方案也可以流畅运行,就我使用这些微型视觉模块的经验来说,我推荐使用MaixCAM pro来实现简单的算法,但是不得不说OpenCV的算法实现是更加通用且灵活的,同时使用OpenCV算法也能够更好的锻炼自己Python水平。

我们所使用的视觉上位机(Jetson Orin Nano)

三、基本方案设计 

1.摄像头的选型

        由于我们想要实现在物料盘以及色环定位时都能够实现精准的对三个目标的识别,那么就需要摄像头有着较广的视角同时要有优秀的自动曝光算法。在学长通过大量的搜索过后,选择使用轮趣的1080P分辨率的C100摄像头作为主要的识别的摄像头。

        关于扫码部分,我也有一些经验想要分享,我们原先在省赛阶段使用的是OpenMV进行扫码,结果发现由于OpenMV本身摄像头分辨率不高,对于环境的适应力不佳(自动曝光效果差且无法消除频闪),差点在省赛决赛时没有扫上二维码。在省赛后的优化中,经过对OpenMV摄像头、USB摄像头和扫码模块的大量测试比对,我们最终选用OpenCV内置的扫码库搭配一个花雀的130度广角的200万高分辨率广角摄像头实现准确的扫码,经过我们大量的测试除了二维码本身出现弯折导致畸变的情况以外,二维码的识别可以达到非常的准确。

车上的摄像头位置
序号项目
1主摄像头(轮趣C100)
2扫码摄像头(花雀200万像素130度广角)

        关于扫码模块的使用,我们也有过尝试,市面上的扫码模块,我个人感觉普遍对扫码距离有着较为苛刻的要求同时扫码区域也较窄(均为60度以下)。扫码时是一个相对黑箱的流程,无法在比赛时通过修改OpenCV算法例如添加直方图均衡来进行调整。我在国赛现场也发现了很多队伍在使用扫码模块时出现受到光线干扰的情况。扫码是整个流程的第一步,但是对于整个视觉流程而言也是至关重要的一步,很多队伍在国赛初赛阶段卡到了扫码,实在是非常可惜。选择一个优秀的USB摄像头进行扫码不仅识别清晰同时也方便查看问题,个人认为是比较好的方案。

2.基本OpenCV的函数设计

        在物料盘抓取以及码垛过程中,我们需要使用OpenCV的颜色识别来追踪和检测物料,下面是借助HSV阈值筛选识别指定物料的函数,同时由于识别物料和决赛时有可能的在物料盘上识别色块两种情况的色块大小不一,添加面积限幅实现对不同目标的追踪。

def color_blocks_position_WL (img,color,size_code): """ 根据命令借助HSV颜色阈值筛选识别指定的物料 :param img: 输入的图像 :param color: 要追踪的颜色目标对象(RGB) :param size_code: 颜色色块面积限幅(用于灵活筛选追踪颜色大小) :return: 相应色块的中心坐标 """ # 定义颜色识别阈值(HSV) color_dist = {'red': {'Lower1': np.array([156, 60, 60]), 'Upper1': np.array([180, 255, 255]),'Lower2': np.array([0, 60, 60]), 'Upper2': np.array([6, 255, 255])}, 'blue': {'Lower': np.array([100, 100, 45]), 'Upper': np.array([124, 255, 255])}, 'green': {'Lower': np.array([38, 80, 45]), 'Upper': np.array([90, 255, 255])}, } ball_color = color if img is not None: gs_img = cv2.GaussianBlur(img, (5, 5), 0) # 高斯模糊 hsv_img = cv2.cvtColor(gs_img, cv2.COLOR_BGR2HSV) # 转化成HSV图像 erode_hsv = cv2.erode(hsv_img, None, iterations=2) # 腐蚀 粗的变细(平整边缘) inRange_hsv = None # 红色在HSV空间阈值有两部分所以需要特殊处理 if (ball_color == 'red'): inRange_hsv1 = cv2.inRange(erode_hsv, color_dist[ball_color]['Lower1'], color_dist[ball_color]['Upper1']) res1 = cv2.bitwise_and(erode_hsv, erode_hsv, mask=inRange_hsv1) inRange_hsv2 = cv2.inRange(erode_hsv, color_dist[ball_color]['Lower2'], color_dist[ball_color]['Upper2']) res2 = cv2.bitwise_and(erode_hsv, erode_hsv, mask=inRange_hsv2) inRange_hsv = inRange_hsv1 + inRange_hsv2 else: inRange_hsv = cv2.inRange(erode_hsv, color_dist[ball_color]['Lower'], color_dist[ball_color]['Upper']) cv2.imshow("end2",erode_hsv) cv2.imshow("end",inRange_hsv) cnts = cv2.findContours(inRange_hsv.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[-2]# 找角点 c = max(cnts, key=cv2.contourArea)# 筛选面积最大的色块 size = int(cv2.contourArea(c)) print(size) if (size > size_code): # 面积限幅 rect = cv2.minAreaRect(c) box = cv2.boxPoints(rect) cv2.drawContours(img0, [np.int0(box)], -1, (0, 255, 255), 2) center_x, center_y = rect[0] return (int(center_x), int(center_y)) # 返回相应色块的中心坐标 else: pass else: print("无画面")

        下面请出学长写的本物流车的视觉核心代码,不惧任何光线的三色环视觉定位算法,经过对原图像腐蚀膨胀后做限制对比度自适应直方图均衡整体计算形态学梯度后经过多次的高斯模糊得到非常稳定的二值化后的圆环图像,最后利用霍夫圆变化得到三个色环的准确的目标。

        本算法的核心思想在于,以前传统算法对于色环的识别,都是先进行色域分割,再进行后续处理。但是在环境光线复杂多变的情况下,色域分割会使得圆环信息在第一步就被丢失。而本算法对所有圆环没有进行颜色的区分,只针对圆环和非环底色之间的色彩差进行放大,从而实现了超强抗干扰。

def color_circle_position (img): """ 色环识别辅助实现定位 :param img: 输入的图像 :return: 三个色环的中心坐标 x1,y1,x2,y2,x3,y3 """ erode_hsv = cv2.erode(img, None, iterations=2) # 腐蚀 粗的变细 kernel = np.ones((7, 7), np.uint8)#5,5 diRange_hsv = cv2.dilate(erode_hsv, kernel, 1) # 膨胀 填补空洞 gray_img = cv2.cvtColor(diRange_hsv, cv2.COLOR_BGR2GRAY) # 转化为单通道灰度图 # 限制对比度自适应直方图均衡,非常好的增强对光线鲁棒性的方法,但是阈值过大容易出现噪点 clahe = cv2.createCLAHE(clipLimit=5.0, tileGridSize=(8, 8))#5.0 clahed = clahe.apply(gray_img) # 对灰度图做 CLAHE 均衡 # 计算形态学梯度(增强物体边缘) kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) gradient = cv2.morphologyEx(gray_img, cv2.MORPH_GRADIENT, kernel) result = cv2.GaussianBlur(gradient, (7, 7), 3, 3) # 高斯模糊 平滑边缘 eqal_img = cv2.convertScaleAbs(result, alpha=4, beta=0) # 再整体增强对比度 cv2.imshow("video2", eqal_img) eqal_img = cv2.GaussianBlur(eqal_img, (7, 7), 3, 3) # 再一次高斯模糊 平滑边缘 retval, threshold_img = cv2.threshold(eqal_img, 70, 255, cv2.THRESH_BINARY) # 二值化 threshold_img = cv2.GaussianBlur(threshold_img, (9, 9), 3, 3) # 最后再来一次高斯模糊 平滑边缘 # canny_img = cv2.Canny(gradient,120,200) # diRange_img = cv2.dilate(canny_img, kernel, 1) # 霍夫圆检测 circles = cv2.HoughCircles(threshold_img, cv2.HOUGH_GRADIENT_ALT, 1.5, 50, param1=100, param2=0.95, minRadius=15, maxRadius=50) cv2.imshow("video", gray_img) cv2.imshow("video3", threshold_img) try: if(len(circles[0])==3): circles = np.uint16(np.around(circles)) # 遍历 for circle in circles[0, :]: cv2.circle(img, (circle[0], circle[1]), circle[2], (0, 0, 255), 2) cv2.circle(img, (circle[0], circle[1]), 2, (255, 0, 0), 2) circle_all=[circles[0][0],circles[0][1],circles[0][2]] circle_list = sorted(circle_all, key=lambda x: x[0]) return (circle_list[0][0],circle_list[0][1],circle_list[1][0],circle_list[1][1],circle_list[2][0],circle_list[2][1]) # 依次返回色环的中心坐标 except: pass

        智能车圈有一个经典老图:

        但是我们的算法做到了在上图环境下的精准识别

        下图是在高强对比度下(阳光照射一半)情况下拍摄的,可以看到三个圆环均被清晰的识别。

3.串口通讯与整体调度

        废话少说这里先挂上一张我们整体调度的流程图,以供大家参考:

      

        之前在打电赛时就曾考虑如何实现上位机和下位机的丝滑切换,我们现在使用Python多线程持续接受串口,下位机用RTOS实现串口通信增加了通讯的实时性,当没有任务就切换为空闲状态,有任务根据任务码进行状态机的切换,下面给出我们串口的函数和部分主函数中的调用示例:

       串口通信函数:

receive=[0,0,0,0] send = [0x66,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00] unit=0 unit_target=0 def uart_process(): """ 串口接收函数 """ global receive #receive the MCU's uart global number global unit #mean the scenario of the map global unit_target #mean the target of the scenario k=0 while True: input = ser.read(4) com_input=list(input) if com_input: # 如果读取结果非空,则输出 print(com_input) try: receive[0]=int(com_input[0]) receive[1]=int(com_input[1]) receive[2]=int(com_input[2]) receive[3]=int(com_input[3]) except: receive=[0,0,0,0] print(receive) if (receive[0]==102): unit=receive[1] unit_target=receive[2] else: None ser = serial.Serial(port="/dev/ttyCH341USB0" , baudrate=115200, timeout=0.05) # 串口名是CH340设备 #ser = serial.Serial(port=list(comport)[0] , baudrate=115200, timeout=0.05) serial_thread = threading.Thread(target=uart_process) # 开启进程绑定事件函数实现串口接收 serial_thread.daemon = True serial_thread.start()

        由于决赛代码我写了一个非常长的判断导致整个While True里面不是很优雅,所以这里只展示一下部分任务切换的逻辑,你们可以借鉴整个思路就好啦!其实主要和大家分享的是整个任务切换的逻辑和思路,同时在这里给出上面一些函数的具体使用方法。我们对于视觉函数返回的值都采用了卡尔曼滤波算法,其实这个算法只是比较好的滤波算法中的一种,如果大家有能力可以考虑使用滑动滤波算法来滤掉抖动。

         部分主函数调用示例(完整代码请移步主链接百度网盘、gitee):

if __name__=="__main__": cap = cv2.VideoCapture(0) while True: success, img0 = cap.read() if success: # 初赛第二轮识别三色物料返回中心点坐标进行物料盘定位 if (unit==1): try: poz_x1,poz_y1 = color_blocks_position_WL(img0,'red',2000) poz_x2,poz_y2 = color_blocks_position_WL(img0,'green',2000) poz_x3,poz_y3 = color_blocks_position_WL(img0,'blue',2000) # 计算三色物料得到的三角形中心 poz_x0 = int((poz_x1+poz_x2+poz_x3)/3) poz_y0 = int((poz_y1+poz_y2+poz_y3)/3) measured_x=poz_x0 measured_y=poz_y0 z = np.array([[measured_x], [measured_y]], dtype=np.float32) x = kalman_filter(z) qx0 = int(x[0][0]) qy0 = int(x[1][0]) send[1]=0x01 send[2]=(qx0&0xff00)>>8 send[3]=(qx0&0xff) send[4]=(qy0&0xff00)>>8 send[5]=(qy0&0xff) send[6]=0x77 FH=bytearray(send) write_len = ser.write(FH) print(send) except: pass # 色环定位 elif (unit==3): try: poz_x1,poz_y1,poz_x2,poz_y2,poz_x3,poz_y3 = color_circle_position(img0) measured_x1=poz_x1 measured_y1=poz_y1 measured_x2=poz_x2 measured_y2=poz_y2 measured_x3=poz_x3 measured_y3=poz_y3 z1 = np.array([[measured_x1], [measured_y1]], dtype=np.float32) x1 = kalman_filter(z1) qx1 = int(x1[0][0]) qy1 = int(x1[1][0]) z2 = np.array([[measured_x2], [measured_y2]], dtype=np.float32) x2 = kalman_filter_2(z2) qx2 = int(x2[0][0]) qy2 = int(x2[1][0]) z3 = np.array([[measured_x3], [measured_y3]], dtype=np.float32) x3 = kalman_filter_3(z3) qx3 = int(x3[0][0]) qy3 = int(x3[1][0]) print(qx2,qy2) send[1]=0x03 send[2]=(qx2&0xff00)>>8 send[3]=(qx2&0xff) send[4]=(qy2&0xff00)>>8 send[5]=(qy2&0xff) send[6]=((qy1-qy3)&0xff00)>>8 send[7]=((qy1-qy3)&0xff) send[8]=0x77 print(qy1-qy3) FH=bytearray(send) write_len = ser.write(FH) print (send) except: pass # 物料识别定位和码垛时底盘定位还是一样返回Y的差值 elif (unit==4): try: poz_x1,poz_y1 = color_blocks_position_WL(img0,'blue',3000) poz_x2,poz_y2 = color_blocks_position_WL(img0,'green',3000) poz_x3,poz_y3 = color_blocks_position_WL(img0,'red',3000) measured_x1=poz_x1 measured_y1=poz_y1 measured_x2=poz_x2 measured_y2=poz_y2 measured_x3=poz_x3 measured_y3=poz_y3 z1 = np.array([[measured_x1], [measured_y1]], dtype=np.float32) x1 = kalman_filter(z1) qx1 = int(x1[0][0]) qy1 = int(x1[1][0]) z2 = np.array([[measured_x2], [measured_y2]], dtype=np.float32) x2 = kalman_filter_2(z2) qx2 = int(x2[0][0]) qy2 = int(x2[1][0]) z3 = np.array([[measured_x3], [measured_y3]], dtype=np.float32) x3 = kalman_filter_3(z3) qx3 = int(x3[0][0]) qy3 = int(x3[1][0]) send[1]=0x04 send[2]=(qx2&0xff00)>>8 send[3]=(qx2&0xff) send[4]=(qy2&0xff00)>>8 send[5]=(qy2&0xff) send[6]=((qy1-qy3)&0xff00)>>8 send[7]=((qy1-qy3)&0xff) send[8]=0x77 print(qx2,qy2) print(qy1-qy3) FH=bytearray(send) write_len = ser.write(FH) except: pass # 扫码摄像头切换,由于是多线程的状态机所以扫码等于就是一帧一帧的扫码 elif (unit==9): # 创建 OpenCV 二维码检测器 qrDecoder = cv2.QRCodeDetector() # 打开二号摄像头 cap2 = cv2.VideoCapture(2) if not cap2.isOpened(): print("无法打开摄像头") else: try: # 从摄像头2读取一帧图像 success, img0 = cap2.read() if not success: print("无法读取摄像头图像") continue # 检测并解码二维码 # 使用 detectAndDecode 方法,它会返回三个值:数据、边界框和矫正后的图像 # 但我们只需要前两个 data, bbox, _ = qrDecoder.detectAndDecode(img0) # 复制原始图像用于绘制 #display_img = img0.copy() if data and bbox is not None: print(f"检测到二维码: 数据 = '{data}'") # 绘制边界框和中心点 display(img0, bbox) cv2.imshow("Results", img0) send[1] = 0x09 send[2] = int(data[0]) send[3] = int(data[1]) send[4] = int(data[2]) send[5] = int(data[4]) send[6] = int(data[5]) send[7] = int(data[6]) send[8] = 0x77 print(send) FH = bytearray(send) write_len = ser.write(FH) else: print("未检测到二维码") cv2.imshow("Results", img0) except: print("error") cap2.release() # 这里释放摄像头2因为出现过摄像头打不开的情况 cv2.destroyAllWindows() else: send[1] = 0xAA send[2] = 0X03 send[3] = 0x77 FH = bytearray(send) write_len = ser.write(FH) print(send) cv2.imshow("videoo", img0) else: break m_key = cv2.waitKey(1) & 0xFF if m_key == ord('q'): break cap.release() cv2.destroyAllWindows() 

注:所有源码将会上传至主链接百度网盘、gitee中

四、关于决赛

        其实我在省赛之前就已经适配的写出了在运动的物料台上动态码垛的算法,详见这个链接:

        工训赛决赛模拟(倒着跑+物料随机摆放+物料盘放置)_哔哩哔哩_bilibili        

        

决赛时物料台码垛

        但是整个代码比较冗长,大概思路是当检测到物料盘状态由动到静时发送从左到右颜色的排序来实现。在今年的国赛社区中,由于夹爪方案的变动导致我们的小车没有带着物料跑到物料台,也是比较遗憾。在国赛决赛中看到很多队采用只判断距离最近的颜色来控制放置的方案,我也写过这种这种方案,是当时国赛社区熬通宵写出来的,这种方案会浪费掉一些时间,可能无法在3分钟之内完赛。上述算法的代码逻辑较为混乱,为了防止给大家造成困惑,这里就暂时不打算开源了。如果后续大家想要了解,请在评论区留言,我会进行整理后发布。

五、后记

        第一次开源这么大的项目,洋洋洒洒也写了1万多字了,工创赛给我两年的大学竞赛生涯画上了圆满的句号。其实我们本可以做的更加完美,从陕西省决赛出来之后我们就商量要为决赛做准备,但在国赛社区比赛时对于夹爪设计的过程中出现与主办方在规则理解上的分歧,同时由于我没有尽早的思考关于决赛动态物料盘上特征由颜色改为条形码后代码修改的细节,间接导致了我们没有在决赛取得更好的名次。

        整个比赛的过程让我感慨万千,我们团队的每个成员都没有遗憾。我们从零开始走到这一步,是经历了省赛比赛的前两天烧掉了Jetson Nano和决赛断掉夹爪的绝望,克服种种困难取得了现在的成绩。

省赛时不幸断掉的夹爪
省赛时不幸断掉的夹爪
省赛前Nano烧掉后使用N97开发板作为上位机的小车

        没有其他人能够比我们更了解,在比赛中从0开始制作一个全新的机械结构的小车的艰辛。从上位机的状况频发,到USB线的制作,这辆车能够实现现在的功能是我们无数个日夜试错得出来的结果,其中出现的问题或许各位都很难想象。

        在这里我最后感谢队伍里学长对我的支持和帮助,能够参与整个项目是我的荣幸,也感谢西安理工大学工程训练中心为我提供的大力支持。在国赛现场看到了很多大佬,有的在传统结构上钻研算法,有的使用了十分新颖的结构。大家都在整个比赛中收获了很多,也衷心希望我的经验能够帮助下一届的学弟学妹们,让工创赛真正越办越好。星光不负赶路人,我们有缘再相见!

六、更新日志

时间更新内容
2025年8月13日初稿发布
2025年8月13日重新排版、勘误

Read more

【前端实战】构建 Vue 全局错误处理体系,实现业务与错误的清晰解耦

【前端实战】构建 Vue 全局错误处理体系,实现业务与错误的清晰解耦

目录 【前端实战】构建 Vue 全局错误处理体系,实现业务与错误的清晰解耦 一、为什么要做全局错误处理? 1、将业务逻辑与错误处理解耦 2、为监控和埋点提供统一入口 二、Vue 中的基础全局错误处理方式 1、Vue 中全局错误处理写法 2、它会捕获哪些错误? 3、它不会捕获哪些错误? 4、errorHandler 的参数含义 三、全局错误处理的进阶设计 1、定义“可识别的业务错误” 2、在 errorHandler 中做真正的“分类处理” 3、补齐 Promise reject 的捕获能力 4、错误处理的策略化封装 四、结语         作者:watermelo37         ZEEKLOG优质创作者、华为云云享专家、阿里云专家博主、腾讯云“

解决 WebView2 中 HostObject 调用窗体关闭时的 `InvalidCastException` 与线程问题

解决 WebView2 中 HostObject 调用窗体关闭时的 InvalidCastException 与线程问题 标签:WPF, WebView2, C#, COM, 多线程, HostObject 在使用 Microsoft Edge WebView2 构建 WPF 桌面应用时,我们经常需要从网页 JavaScript 中调用 .NET 方法——例如点击网页按钮后关闭当前窗口。这通常通过 AddHostObjectToScript 注入一个 [ComVisible] 的 .NET 对象(称为 HostObject)来实现。 然而,许多开发者会遇到如下异常: System.InvalidCastException: 无法将类型为“System.__ComObject”的 COM 对象强制转换为接口类型 “Microsoft.Web.WebView2.

排序算法指南:快速排序(非递归)

排序算法指南:快速排序(非递归)

前言:          本文将通过图解与代码相结合的方式,详细介绍快速排序的非递归实现方法。虽然前文已展示递归实现方案,但在实际面试中,面试官更倾向于考察非递归版本的实现。这种实现方式不仅能加深对算法的理解,还能展现应聘者对栈结构的掌握程度。          一、非递归实现快排的思路          1.1核心原理:手动模拟栈                   在标准的递归快速排序中,当我们写下 quickSort(a,left, right) 时,系统会自动分配一块内存(函数调用栈)来记住当前的 left 和 right 是多少,以及函数执行完后该回到哪里。         在非递归版本中,我们不需要系统帮忙,而是自己创建一个栈(Stack)数据结构。          1.2核心操作:用栈存取数组区间          ① 向栈中存储操作:存储每一次需要排序的子数组的起止下标(begin,end)。                                  由于栈的特性是先进后出,我们优先处理左区间,再处理右区间,类似于二叉树的前序操

一文吃透 DFS 深度优先搜索:从原理到实战

在算法世界中,深度优先搜索(Depth-First Search,简称 DFS)是一种基础且核心的遍历与搜索算法。它以 “纵向挖掘” 为核心思想,沿着一条路径探索至终点后,回溯至前一节点继续探索其他路径,如同迷宫中沿着一条通道走到头,再折返寻找新通道。这种特性让 DFS 在图论遍历、迷宫求解、组合问题、拓扑排序等场景中广泛应用,也是面试与算法竞赛的高频考点。本文将从原理、实现、优化到实战,带你全面掌握 DFS。 一、DFS 核心原理:纵向探索与回溯 1. 核心思想 DFS 的本质是 “不撞南墙不回头”:从起始节点出发,优先探索当前节点的某一条邻接路径,直至无法继续前进(到达终点或已访问节点),再回溯到上一个未探索完所有邻接节点的节点,重复此过程,直至遍历所有可达节点。 2. 关键概念 * 访问标记:为避免重复访问节点(陷入死循环),需用数组或哈希表记录节点是否已被访问。 * 回溯:探索完一条路径后,