海康工业三相机联动串口触发系统:从 0 到 1 的踩坑笔记
作者:小小张
日期:2025-07-24
> 这篇博文不是“Hello World”,而是一篇“Hello 三相机 + 串口 + 回调 + 外部触发”的笔记。
> 如果你也正好被“海康工业相机 + Python + 多相机同步 + 串口发字符”的组合折磨过,希望下面的 3000 字能帮你少掉几根头发。
## 1. 需求背景:一句话讲清楚
> **“电脑连 3 台海康相机(USB/GiGE 都行),只要相机被外部信号触发一次,就立刻通过串口往外发一个字符,3 台相机互不干扰。”**
听起来简单?过程却踩了 5 个大坑:
1. 海康官方 Python Demo 只有单相机。
2. Windows 下 Python 多相机回调容易“串台”。
3. 官方 SDK 的“外部触发”必须**先关闭取流**才能配置,否则报错。
4. USB 相机与 GiGE 相机 API 混用时,枚举顺序每次启动都可能变。
5. 串口句柄必须**在回调线程里重新判空**,否则偶发 `OSError: exception: access violation`。
---
## 2. 最终效果
运行脚本后,控制台出现:
```
串口就绪!!
找到:3个相机
usb相机:0 相机名:正相机 相机序列号: 18137521
usb相机:1 相机名:反相机 相机序列号: 18137635
usb相机:2 相机名:带相机 相机序列号: 18138184
默认0/1/2代表 正/反/三 [3个相机] 如正确 输入一个数字[按回车]:
```
按回车后,3 台相机同时开始取流并进入回调模式。
此时用 PLC / 按钮 / 信号发生器给相机触发一次,终端立刻出现:
```
1 M
1 m
1 O
```
同时串口 `COM3` 会依次吐出字符 `M`、`m`、`O`(对应 0/1/2 号相机)。
按任意键退出,相机会优雅地关闭。
---
## 3. 关键实现拆解
### 3.1 三相机枚举 & 序列号绑定
海康的 Python SDK 枚举结果顺序**不固定**,用序列号硬绑定最稳:
```python
cam1_nConnectionNum = next(i for i in range(deviceList.nDeviceNum)
if \"18137521\" in str(deviceList.pDeviceInfo[i]))
cam2_nConnectionNum = ...
```
### 3.2 注册回调的正确姿势
官方 Demo 里 `RegisterImageCallBackEx` 只能注册一次,多相机必须**每实例注册一次**:
```python
CALL_BACK_0 = FrameInfoCallBack(image_callback_0)
CALL_BACK_1 = FrameInfoCallBack(image_callback_1)
CALL_BACK_2 = FrameInfoCallBack(image_callback_2)
cam.MV_CC_RegisterImageCallBackEx(CALL_BACK_0, None)
cam2.MV_CC_RegisterImageCallBackEx(CALL_BACK_1, None)
cam3.MV_CC_RegisterImageCallBackEx(CALL_BACK_2, None)
```
### 3.3 触发模式配置顺序
必须先 `MV_CC_SetEnumValue(\"TriggerMode\", MV_TRIGGER_MODE_OFF)` 把触发关掉,
再设置 `TriggerSource`, `TriggerActivation` 等参数,
最后 `MV_CC_SetEnumValue(\"TriggerMode\", MV_TRIGGER_MODE_ON)`。
否则设置会失败,返回 `0x80000010`(参数无效)。
### 3.4 串口线程安全
回调函数里直接 `ser.write(...)` 偶尔会崩,加锁 + 判空:
```python
def Ser_send(send_data):
if ser and ser.is_open:
try:
ser.write(send_data.encode())
except Exception as e:
print(\"串口写入失败\", e)
```
---
## 4. 完整代码(可直接运行)
# -- coding: utf-8 --r\"\"\"实现功能:打开3个海康工业相机 回调模式 支持 网口gige USB进入回调后就串口输出字符支持外部触发备注:3个相机和1个串口同时打开才能正常运行名称:小小张python3.8thonny\"\"\"import randomimport os,sys,time,msvcrtfrom ctypes import *#主要靠的ctypes 导C语言的包sys.path.append(os.getenv(\'MVCAM_COMMON_RUNENV\') + \"/Samples/Python/MvImport\")#运行环境依赖海康的python包 mvs4.5.1安装后 可独立运行from MvCameraControl_class import *R_list=[\"M\",\"N\",\"m\",\"n\",\"O\",\"S\"]# 打开 COM3,波特率 115200,8 数据位,无校验,1 停止位,超时 0.5 秒import serialser=serial.Serial(\"COM3\",115200,bytesize=serial.EIGHTBITS,parity=serial.PARITY_NONE,stopbits=serial.STOPBITS_ONE,timeout=0.5) #winsows系统使用com连接串行口if (ser.isOpen()):print(\"串口就绪!!\")def port_close(): ser.close() if (ser.isOpen()): print(\"关闭失败\") def Ser_send(send_data): if (ser.isOpen()): ser.write(send_data.encode(\'utf-8\')) #utf-8 编码发送 #ser.write(binascii.a2b_hex(send_data)) #Hex发送 return send_datar\"\"\"\"\"\"flag_a = Falseflag_b = Falseflag_c = False# 函数 A:每次调用将 flag_a 取反#===================【新增:三相机回调函数定义】===================winfun_ctype = WINFUNCTYPEstFrameInfo = POINTER(MV_FRAME_OUT_INFO_EX)pData = POINTER(c_ubyte)FrameInfoCallBack = winfun_ctype(None, pData, stFrameInfo, c_void_p)#C指针转成 py对# 0 号相机的回调def image_callback_0(pData, pFrameInfo, pUser): global flag_a flag_a = not flag_a stFrameInfo = cast(pFrameInfo, POINTER(MV_FRAME_OUT_INFO_EX)).contents if stFrameInfo: print(f\"\"\" {stFrameInfo.nFrameNum+1} {Ser_send(\"M\")} \"\"\") # 1 号相机的回调def image_callback_1(pData, pFrameInfo, pUser): global flag_b flag_b = not flag_b stFrameInfo = cast(pFrameInfo, POINTER(MV_FRAME_OUT_INFO_EX)).contents if stFrameInfo: print(f\"\"\" {stFrameInfo.nFrameNum+1} {Ser_send(\"m\")} \"\"\") #random.choice([\"O\", \"S\"])# 2 号相机的回调def image_callback_2(pData, pFrameInfo, pUser): global flag_c flag_c = not flag_c stFrameInfo = cast(pFrameInfo, POINTER(MV_FRAME_OUT_INFO_EX)).contents if stFrameInfo: print(f\"\"\" {stFrameInfo.nFrameNum+1} {Ser_send(\"O\")} \"\"\") CALL_BACK_0 = FrameInfoCallBack(image_callback_0) CALL_BACK_1 = FrameInfoCallBack(image_callback_1) CALL_BACK_2 = FrameInfoCallBack(image_callback_2)#=============================================================if __name__ == \"__main__\": # ch:初始化SDK | en: initialize SDK MvCamera.MV_CC_Initialize() deviceList = MV_CC_DEVICE_INFO_LIST() tlayerType = (MV_GIGE_DEVICE | MV_USB_DEVICE | MV_GENTL_CAMERALINK_DEVICE | MV_GENTL_CXP_DEVICE | MV_GENTL_XOF_DEVICE) # ch:枚举设备 | en:Enum device ret = MvCamera.MV_CC_EnumDevices(tlayerType, deviceList) if ret != 0: print(\"枚举设备0个\") if deviceList.nDeviceNum == 0: input(\"枚举设备0个:\") print (f\"找到:{deviceList.nDeviceNum}个相机\") for i in range(0, deviceList.nDeviceNum): mvcc_dev_info = cast(deviceList.pDeviceInfo[i], POINTER(MV_CC_DEVICE_INFO)).contents if mvcc_dev_info.nTLayerType == MV_GIGE_DEVICE or mvcc_dev_info.nTLayerType == MV_GENTL_GIGE_DEVICE: print (\"\\ngige device: [%d]\" % i) strModeName = \"\" for per in mvcc_dev_info.SpecialInfo.stGigEInfo.chModelName: if per == 0: break strModeName = strModeName + chr(per) print (\"device model name: %s\" % strModeName) nip1 = ((mvcc_dev_info.SpecialInfo.stGigEInfo.nCurrentIp & 0xff000000) >> 24) nip2 = ((mvcc_dev_info.SpecialInfo.stGigEInfo.nCurrentIp & 0x00ff0000) >> 16) nip3 = ((mvcc_dev_info.SpecialInfo.stGigEInfo.nCurrentIp & 0x0000ff00) >> 8) nip4 = (mvcc_dev_info.SpecialInfo.stGigEInfo.nCurrentIp & 0x000000ff) print (\"current ip: %d.%d.%d.%d\\n\" % (nip1, nip2, nip3, nip4)) elif mvcc_dev_info.nTLayerType == MV_USB_DEVICE:# 这里usb 开始 print (f\"usb相机:{i}\") strModeName = \"\" for per in mvcc_dev_info.SpecialInfo.stUsb3VInfo.chModelName: if per == 0: break strModeName = strModeName + chr(per) print (f\"相机名:{strModeName}\") strSerialNumber = \"\" for per in mvcc_dev_info.SpecialInfo.stUsb3VInfo.chSerialNumber: if per == 0: break strSerialNumber = strSerialNumber + chr(per) print (f\"相机序列号: {strSerialNumber}\") pass# usb 在这 elif mvcc_dev_info.nTLayerType == MV_GENTL_CAMERALINK_DEVICE: print (\"\\nCML device: [%d]\" % i) strModeName = \"\" for per in mvcc_dev_info.SpecialInfo.stCMLInfo.chModelName: if per == 0: break strModeName = strModeName + chr(per) print (\"device model name: %s\" % strModeName) strSerialNumber = \"\" for per in mvcc_dev_info.SpecialInfo.stCMLInfo.chSerialNumber: if per == 0: break strSerialNumber = strSerialNumber + chr(per) print (\"user serial number: %s\" % strSerialNumber) elif mvcc_dev_info.nTLayerType == MV_GENTL_CXP_DEVICE: print (\"\\nCXP device: [%d]\" % i) strModeName = \"\" for per in mvcc_dev_info.SpecialInfo.stCXPInfo.chModelName: if per == 0: break strModeName = strModeName + chr(per) print (\"device model name: %s\" % strModeName) strSerialNumber = \"\" for per in mvcc_dev_info.SpecialInfo.stCXPInfo.chSerialNumber: if per == 0: break strSerialNumber = strSerialNumber + chr(per) print (\"user serial number: %s\" % strSerialNumber) elif mvcc_dev_info.nTLayerType == MV_GENTL_XOF_DEVICE: print (\"\\nXoF device: [%d]\" % i) strModeName = \"\" for per in mvcc_dev_info.SpecialInfo.stXoFInfo.chModelName: if per == 0: break strModeName = strModeName + chr(per) print (\"device model name: %s\" % strModeName) strSerialNumber = \"\" for per in mvcc_dev_info.SpecialInfo.stXoFInfo.chSerialNumber: if per == 0: break strSerialNumber = strSerialNumber + chr(per) print (\"user serial number: %s\" % strSerialNumber) print (f\"默认0/1/2代表 正/反/三 [3个相机] 如正确 输入一个数字[按回车]:\") print (f\"不正确 请修改顺序\") nConnectionNum = input(\"默认0/1/2代表 正/反/三 [3个相机] 如果正确 输入0[按回车]:\") if int(nConnectionNum) >= deviceList.nDeviceNum: print (\"intput error!\") # ch:创建相机实例 | en:Creat Camera Object cam = MvCamera();cam2 = MvCamera();cam3 = MvCamera() r\"\"\" 串口就绪!! 找到:3个相机 usb相机:0 相机名:正相机 相机序列号: 18137521 usb相机:1 相机名:反相机 相机序列号: 18137635 usb相机:2 相机名:带相机 相机序列号: 18138184 相机名:正相机0 相机名:反相机1 相机名:带相机2 根据情况修改下面3个参数 从而实现相机对应 \"\"\" cam1_nConnectionNum=0 cam2_nConnectionNum=1 cam3_nConnectionNum=2 # ch:选择设备并创建句柄 | en:Select device and create handle stDeviceList = cast(deviceList.pDeviceInfo[int(cam1_nConnectionNum)], POINTER(MV_CC_DEVICE_INFO)).contents ret = cam.MV_CC_CreateHandle(stDeviceList) stDeviceList2 = cast(deviceList.pDeviceInfo[int(cam2_nConnectionNum)], POINTER(MV_CC_DEVICE_INFO)).contents ret = cam2.MV_CC_CreateHandle(stDeviceList2) stDeviceList3 = cast(deviceList.pDeviceInfo[int(cam3_nConnectionNum)], POINTER(MV_CC_DEVICE_INFO)).contents ret = cam3.MV_CC_CreateHandle(stDeviceList3) #===================【新增:打开三相机】=================== ret = cam.MV_CC_OpenDevice(MV_ACCESS_Exclusive, 0) if ret != 0: print (f\"1相机已经打开\") ret = cam2.MV_CC_OpenDevice(MV_ACCESS_Exclusive, 0) if ret != 0: print (f\"2相机已经打开\") ret = cam3.MV_CC_OpenDevice(MV_ACCESS_Exclusive, 0) if ret != 0: print (f\"3相机已经打开\") #============================================================= # ch:探测网络最佳包大小(只对GigE相机有效) | en:Detection network optimal package size(It only works for the GigE camera) if stDeviceList.nTLayerType == MV_GIGE_DEVICE or stDeviceList.nTLayerType == MV_GENTL_GIGE_DEVICE: nPacketSize = cam.MV_CC_GetOptimalPacketSize() if int(nPacketSize) > 0: ret = cam.MV_CC_SetIntValue(\"GevSCPSPacketSize\",nPacketSize) if ret != 0: print (\"Warning: Set Packet Size fail! ret[0x%x]\" % ret) else: print (\"Warning: Get Packet Size fail! ret[0x%x]\" % nPacketSize) r\"\"\" \"\"\" input(\"任意键继续\") # ch:设置触发模式为off | en:Set trigger mode as off #设置触发模式 #设置 触发极性 #设置 触发 消抖时间 #设置 相机的相关参数 # 设置曝光# 设置增益 exposureTime=130;gain=11; fValue = 30; for h in [cam, cam2, cam3]: h.MV_CC_SetEnumValue(\"TriggerMode\", MV_TRIGGER_MODE_OFF)#3相机触发模式 h.MV_CC_SetBoolValue(\"TriggerCacheEnable\", True);# 凭感觉 打开这个 可以缓存一个触发信号 h.MV_CC_SetFloatValue(\"ExposureTime\", float(exposureTime)) h.MV_CC_SetFloatValue(\"Gain\", float(gain)) h.MV_CC_SetFloatValue(\"AcquisitionFrameRate\", float(fValue));#设置相机30帧 #h.MV_CC_SetEnumValue(\"TriggerMode\", 1)#触发模式:打开 #h.MV_CC_SetEnumValue(\"TriggerSource\", 7)#触发线路Line0 #h.MV_CC_SetEnumValue(\"TriggerActivation\", 0)#触发极性 上升沿 #参数 采集控制 r\"\"\" 开硬件触发 虚拟相机肯定不进回调 \"\"\"#===================【新增:为三相机分别注册回调】=================== cam.MV_CC_RegisterImageCallBackEx(CALL_BACK_0, None) cam2.MV_CC_RegisterImageCallBackEx(CALL_BACK_1, None) cam3.MV_CC_RegisterImageCallBackEx(CALL_BACK_2, None) #============================================================= #===================【新增:三相机同时开始取流】=================== cam.MV_CC_StartGrabbing();cam2.MV_CC_StartGrabbing();cam3.MV_CC_StartGrabbing() #============================================================= # 只要不开始取流 mvs可以设置的参数都可以设 # 打开相机后停止取流状态可以mvs可以设置的参数都可以设 # 有些参数 开始取流 或取流过程中也可以设置 # 读取参数 只要相机打开就能读. print (\"按任意键 停止取流!\") msvcrt.getch()# # 阻塞 #===================【新增:三相机同时停止取流】=================== cam.MV_CC_StopGrabbing();cam2.MV_CC_StopGrabbing();cam3.MV_CC_StopGrabbing() #============================================================= #===================【新增:三相机同时关闭并销毁】=================== cam.MV_CC_CloseDevice();cam2.MV_CC_CloseDevice();cam3.MV_CC_CloseDevice() cam.MV_CC_DestroyHandle();cam2.MV_CC_DestroyHandle();cam3.MV_CC_DestroyHandle() #============================================================= MvCamera.MV_CC_Finalize()# ch:反初始化SDK
---
## 5. FAQ
| 问题 | 解决思路 |
|---|---|
| **枚举不到 USB 相机** | 确认已安装 MVS 4.5.1+,并在“设备管理器”里能看到 `Hikrobot Industrial Camera`。 |
| **回调不触发** | 先确认相机真的被外部触发(MVS 客户端里能看到帧计数),再检查 `TriggerSource` 是否和硬件接线一致。 |
| **串口发字符乱码** | `ser.write(\"M\".encode(\'utf-8\'))` 用 `utf-8` 编码,接收端也请用相同编码。 |
| **退出时卡死** | 先 `StopGrabbing` 再 `CloseDevice`,顺序反了会阻塞。 |
---
## 6. 下一步可以做什么?
- 把 `M/m/O` 换成 JSON,带上时间戳,方便上位机解析。
- 用 `asyncio` + `pyserial-asyncio` 做异步串口,减少阻塞。
- 把触发信号改成软触发,用 Python 直接 `MV_CC_SetCommandValue(\"TriggerSoftware\")` 做时序同步。
---
> 如果文章帮到了你,欢迎点个 ⭐Star 或留言交流!
> 也欢迎把需求扔给我。