作者:小小张
日期: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.8
thonny
"""
import random
import os,sys,time,msvcrt
from 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 serial
ser=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_data
r"""
"""
flag_a = False
flag_b = False
flag_c = False
# 函数 A:每次调用将 flag_a 取反
#===================【新增:三相机回调函数定义】===================
winfun_ctype = WINFUNCTYPE
stFrameInfo = 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 或留言交流!
> 也欢迎把需求扔给我。