feat: add PnP laser solving and update 0xA5 frame to 4-float protocol

- Integrate PnP rectangle pose estimation + laser world coordinate computation from scripts/calibrate/laser_pnp.py
- Publish normalized measurement (laser/RECT) and reference (rect center) via Float32MultiArray
- Update gimbal 0xA5 frame to 18-byte format: measure_x, ref_x, measure_y, ref_y
- Add camera calibration, laser line params, and RECT_L/W constants
- Add corner ordering (TL/BL/BR/TR), SOLVEPNP_IPPE, and pose/laser visualization overlay
- Remove standalone vision_example.py (superseded by ROS2 nodes)
- Add scripts/calibrate/ with calibration and PnP tools
This commit is contained in:
dev01
2026-06-03 22:38:51 +08:00
parent 0340d40b84
commit fd3d6b7ab5
10 changed files with 2549 additions and 81 deletions
+109
View File
@@ -0,0 +1,109 @@
# 相机标定与激光点解算
## 文件说明
| 文件 | 用途 |
|------|------|
| `calibrate.py` | 相机内参标定,交互式拍照,输出 `calibration_result.json` |
| `laser_calibrate.py` | 激光器-相机联合标定,拟合相机坐标系下的激光直线,输出 `laser_result.json` |
| `pnp_solve.py` | 矩形检测 + PnP 位姿解算,实时显示相机位姿 |
| `laser_pnp.py` | 矩形检测 + PnP 位姿解算 + 激光点世界坐标计算 |
## 棋盘格规格
- 方格边长:**15 mm**
- 内角点数:**11 列 x 8 行**(12x9 个方格)
---
## 工作流程
### 第一步:相机内参标定
```bash
python calibrate.py
```
1. 输入是否为无畸变镜头(y/n),然后输入拍摄数量(建议 15~20 张)
2. 将棋盘格以不同角度/位置放在相机视野中
3. 画面显示绿色 **CHESSBOARD DETECTED** 后,输入 `y` 并回车拍照
4. 图像自动保存到 `calibration_images/` 目录
5. 拍完后自动标定,输出内参矩阵 K 和畸变系数
6. 结果保存为 `calibration_result.json`,同时终端打印完整 JSON
### 第二步(可选):激光器-相机联合标定
> 需要开启激光,激光点投射在标定板上
```bash
python laser_calibrate.py
```
1. 自动加载 `calibration_result.json`
2. 输入拍摄数量(建议 >= 10 张),标定板以不同姿态摆放
3. 画面显示绿色棋盘格角点 + 红色激光点圆圈后,输入 `y` 拍照
4. 每张图像:PnP 解算标定板位姿 -> 提取激光点像素 -> 反投影求交 -> 得到相机坐标系 3D 点
5. 图像自动保存到 `laser_calib_images/` 目录
6. 采集完成后 PCA 拟合激光直线,输出:
- `d_cam`:激光方向向量(相机坐标系)
- `O_cam`:基准起点(直线与相机 Z=0 平面的交点)
7. 结果保存为 `laser_result.json`,同时终端打印完整 JSON
### 第三步:在线运行
**仅 PnP 位姿解算:**
```bash
python pnp_solve.py
```
将 `calibrate.py` 输出的 JSON 粘贴到文件顶部的 `CALIB_JSON` 变量中,修改 `RECT_L`/`RECT_W` 为实际矩形尺寸。
**PnP 位姿 + 激光点世界坐标:**
```bash
python laser_pnp.py
```
将 `calibrate.py` 输出的 JSON 粘贴到 `CALIB_JSON`,将 `laser_calibrate.py` 输出的 JSON 粘贴到 `LASER_JSON`,修改 `RECT_L`/`RECT_W`。
画面实时显示:
- 矩形四个角点标注(TL/BL/BR/TR)
- 右手坐标系坐标轴(X 红 / Y 绿 / Z 蓝)
- 相机平移向量 T 和旋转欧拉角 R
- 激光点世界坐标 (x, y)(仅在 `laser_pnp.py` 中)
---
## 内参矩阵缩放
内参矩阵 K 的单位是像素,与图像分辨率绑定。畸变系数(`dist`)是无量纲的,不随分辨率变化。
等比例缩放因子 $s = W_{\text{new}} / W_{\text{orig}}$:
```python
K_scaled = K.copy()
K_scaled[0, 0] *= s # fx
K_scaled[1, 1] *= s # fy
K_scaled[0, 2] *= s # cx
K_scaled[1, 2] *= s # cy
```
对应的 `pnp_solve.py` 和 `laser_pnp.py` 通过 `IMAGE_SCALE` 常量自动处理缩放。
---
## 坐标系约定
**世界坐标系**(矩形平面,Z=0):
```
TL=(0,0) ──── TR=(l,0)
│ │
BL=(0,w) ──── BR=(l,w)
```
- X 轴沿矩形长边,向右
- Y 轴沿矩形宽边,向下
- Z 轴由右手定则确定:X x Y = Z,指向观察者
**相机坐标系**:OpenCV 标准右手系,X 右、Y 下、Z 前(指向场景)。
+504
View File
@@ -0,0 +1,504 @@
# -- coding: utf-8 --
"""
交互式相机内参标定程序
使用方格标定板(15mm方格边长),实时预览,终端交互拍照,自动标定内参矩阵。
"""
import sys
import platform
import os
from ctypes import *
import numpy as np
import cv2
import json
# ===== 标定参数配置 =====
SQUARE_SIZE = 15.0 # 方格边长 (mm)
CHESSBOARD_COLS = 11 # 内角点列数
CHESSBOARD_ROWS = 8 # 内角点行数
DETECT_INTERVAL = 3 # 每隔多少帧检测一次棋盘格(减少CPU负载)
# =======================
currentsystem = platform.system()
print(f"current system: {currentsystem}")
if currentsystem == 'Windows':
sys.path.append(os.getenv('MVCAM_COMMON_RUNENV') + "/Samples/Python/MvImport")
else:
sys.path.append("/opt/MVS/Samples/aarch64/Python/MvImport")
from MvCameraControl_class import *
if sys.version_info[0] < 3:
input_func = raw_input
else:
input_func = input
# ===== 全局状态 =====
g_capture_flag = False
g_captured_frames = []
g_image_dir = "calibration_images"
g_frame_count = 0
g_cb_detected = False # 当前帧是否检测到棋盘格
g_cb_corners_display = None # 角点坐标(显示图像坐标系)
stFrame = POINTER(MV_FRAME_OUT)
fun_ctype = get_platform_functype()
FrameInfoCallBack2 = fun_ctype(None, stFrame, c_void_p, c_bool)
def IsHBPixelFormat(enPixelType=0):
if enPixelType in (PixelType_Gvsp_HB_Mono8,
PixelType_Gvsp_HB_Mono10,
PixelType_Gvsp_HB_Mono10_Packed,
PixelType_Gvsp_HB_Mono12,
PixelType_Gvsp_HB_Mono12_Packed,
PixelType_Gvsp_HB_Mono16,
PixelType_Gvsp_HB_RGB8_Packed,
PixelType_Gvsp_HB_BGR8_Packed,
PixelType_Gvsp_HB_RGBA8_Packed,
PixelType_Gvsp_HB_BGRA8_Packed,
PixelType_Gvsp_HB_RGB16_Packed,
PixelType_Gvsp_HB_BGR16_Packed,
PixelType_Gvsp_HB_RGBA16_Packed,
PixelType_Gvsp_HB_BGRA16_Packed,
PixelType_Gvsp_HB_YUV422_Packed,
PixelType_Gvsp_HB_YUV422_YUYV_Packed,
PixelType_Gvsp_HB_BayerGR8,
PixelType_Gvsp_HB_BayerRG8,
PixelType_Gvsp_HB_BayerGB8,
PixelType_Gvsp_HB_BayerBG8,
PixelType_Gvsp_HB_BayerRBGG8,
PixelType_Gvsp_HB_BayerGB10,
PixelType_Gvsp_HB_BayerGB10_Packed,
PixelType_Gvsp_HB_BayerBG10,
PixelType_Gvsp_HB_BayerBG10_Packed,
PixelType_Gvsp_HB_BayerRG10,
PixelType_Gvsp_HB_BayerRG10_Packed,
PixelType_Gvsp_HB_BayerGR10,
PixelType_Gvsp_HB_BayerGR10_Packed,
PixelType_Gvsp_HB_BayerGB12,
PixelType_Gvsp_HB_BayerGB12_Packed,
PixelType_Gvsp_HB_BayerBG12,
PixelType_Gvsp_HB_BayerBG12_Packed,
PixelType_Gvsp_HB_BayerRG12,
PixelType_Gvsp_HB_BayerRG12_Packed,
PixelType_Gvsp_HB_BayerGR12,
PixelType_Gvsp_HB_BayerGR12_Packed):
return True
else:
return False
def IsMonoPixelFormat(enPixelType=0):
if enPixelType in (PixelType_Gvsp_Mono8,
PixelType_Gvsp_Mono10,
PixelType_Gvsp_Mono10_Packed,
PixelType_Gvsp_Mono12,
PixelType_Gvsp_Mono12_Packed,
PixelType_Gvsp_Mono14,
PixelType_Gvsp_Mono16):
return True
else:
return False
def image_callback(pstFrame, pUser, bAutoFree):
global g_capture_flag, g_captured_frames, g_frame_count
global g_cb_detected, g_cb_corners_display
stFrame = cast(pstFrame, POINTER(MV_FRAME_OUT)).contents
User = ctypes.cast(pUser, ctypes.py_object).value
if not stFrame:
return
while True:
stDecodeParam = MV_CC_HB_DECODE_PARAM()
stConvertParam = MV_CC_PIXEL_CONVERT_PARAM_EX()
memset(byref(stConvertParam), 0, sizeof(stConvertParam))
result = IsHBPixelFormat(stFrame.stFrameInfo.enPixelType)
if True == result:
DecodeBufferlen = stFrame.stFrameInfo.nWidth * stFrame.stFrameInfo.nHeight * 3
DecodeBuffer = (c_ubyte * DecodeBufferlen)()
stDecodeParam.pSrcBuf = stFrame.pBufAddr
stDecodeParam.nSrcLen = stFrame.stFrameInfo.nFrameLen
stDecodeParam.pDstBuf = DecodeBuffer
stDecodeParam.nDstBufSize = DecodeBufferlen
ret = User.MV_CC_HBDecode(stDecodeParam)
if ret != 0:
print("HB Decode fail! ret[0x%x]" % ret)
break
stConvertParam.pSrcData = stDecodeParam.pDstBuf
stConvertParam.nSrcDataLen = stDecodeParam.nDstBufLen
stConvertParam.enSrcPixelType = stDecodeParam.enDstPixelType
else:
stConvertParam.pSrcData = stFrame.pBufAddr
stConvertParam.nSrcDataLen = stFrame.stFrameInfo.nFrameLen
stConvertParam.enSrcPixelType = stFrame.stFrameInfo.enPixelType
enDstPixelType = PixelType_Gvsp_Undefined
nChannelNum = 1
bMono = IsMonoPixelFormat(stConvertParam.enSrcPixelType)
if True == bMono:
enDstPixelType = PixelType_Gvsp_Mono8
nChannelNum = 1
else:
enDstPixelType = PixelType_Gvsp_BGR8_Packed
nChannelNum = 3
convertdestBufflen = nChannelNum * stFrame.stFrameInfo.nWidth * stFrame.stFrameInfo.nHeight
DstBuffer = (c_ubyte * convertdestBufflen)()
DstBufferlen = convertdestBufflen
stConvertParam.nWidth = stFrame.stFrameInfo.nWidth
stConvertParam.nHeight = stFrame.stFrameInfo.nHeight
stConvertParam.enDstPixelType = enDstPixelType
stConvertParam.pDstBuffer = DstBuffer
stConvertParam.nDstBufferSize = DstBufferlen
ret = User.MV_CC_ConvertPixelTypeEx(stConvertParam)
if ret != 0:
print("convert pixel fail! ret[0x%x]" % ret)
break
# 构建全分辨率图像
if nChannelNum == 1:
numpy_image = np.frombuffer(DstBuffer, dtype=np.ubyte, count=DstBufferlen).reshape(
stFrame.stFrameInfo.nHeight, stFrame.stFrameInfo.nWidth)
display_image = cv2.cvtColor(numpy_image, cv2.COLOR_GRAY2BGR)
else:
numpy_image = np.frombuffer(DstBuffer, dtype=np.ubyte, count=DstBufferlen).reshape(
stFrame.stFrameInfo.nHeight, stFrame.stFrameInfo.nWidth, 3)
display_image = cv2.resize(numpy_image, None, fx=0.5, fy=0.5, interpolation=cv2.INTER_AREA)
# 每隔 DETECT_INTERVAL 帧在显示图像上检测棋盘格(速度快)
if g_frame_count % DETECT_INTERVAL == 0:
gray_display = cv2.cvtColor(display_image, cv2.COLOR_BGR2GRAY) if nChannelNum == 3 else numpy_image
ret_cb, corners_disp = cv2.findChessboardCorners(
gray_display, (CHESSBOARD_COLS, CHESSBOARD_ROWS),
cv2.CALIB_CB_ADAPTIVE_THRESH + cv2.CALIB_CB_NORMALIZE_IMAGE
)
g_cb_detected = ret_cb
if ret_cb:
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)
g_cb_corners_display = cv2.cornerSubPix(
gray_display, corners_disp, (5, 5), (-1, -1), criteria)
else:
g_cb_corners_display = None
else:
ret_cb = g_cb_detected
# 在显示图像上绘制检测结果
if g_cb_detected and g_cb_corners_display is not None:
cv2.drawChessboardCorners(display_image, (CHESSBOARD_COLS, CHESSBOARD_ROWS),
g_cb_corners_display, True)
# 处理拍照指令(保存全分辨率图像,标定时会重新精确检测角点)
if g_capture_flag:
if g_cb_detected:
g_captured_frames.append(numpy_image.copy())
idx = len(g_captured_frames)
path = os.path.join(g_image_dir, f"calib_{idx:02d}.png")
cv2.imwrite(path, numpy_image)
print(f" [OK] 已捕获第 {idx} 张图像 -> {path}")
g_capture_flag = False
# 若未检测到棋盘格,保持标志等待下一帧
# 绘制状态信息
status_text = f"Captured: {len(g_captured_frames)}"
if g_cb_detected:
cv2.putText(display_image, "CHESSBOARD DETECTED", (10, 30),
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
if g_capture_flag and not g_cb_detected:
cv2.putText(display_image, "Waiting for chessboard...", (10, 60),
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
cv2.putText(display_image, status_text, (10, display_image.shape[0] - 10),
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2)
cv2.imshow('Calibration - Live View', display_image)
cv2.waitKey(1)
g_frame_count += 1
break
if bAutoFree == False:
if pUser != None:
User.MV_CC_FreeImageBuffer(stFrame)
else:
print("user is null, invalid")
CALL_BACK_FUN = FrameInfoCallBack2(image_callback)
def run_calibration(distortion_free=False):
"""使用已捕获的全分辨率图像进行相机内参标定"""
if len(g_captured_frames) == 0:
print("错误:没有捕获到任何图像!")
return
print("\n正在对全分辨率图像进行亚像素角点检测...")
objp = np.zeros((CHESSBOARD_COLS * CHESSBOARD_ROWS, 3), np.float32)
objp[:, :2] = np.mgrid[0:CHESSBOARD_COLS, 0:CHESSBOARD_ROWS].T.reshape(-1, 2) * SQUARE_SIZE
objpoints = []
imgpoints = []
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)
for i, img in enumerate(g_captured_frames):
if len(img.shape) == 2:
gray = img
elif img.shape[2] == 1:
gray = img[:, :, 0] if hasattr(img, 'shape') and len(img.shape) == 3 else img
else:
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
if len(gray.shape) == 3:
gray = cv2.cvtColor(gray, cv2.COLOR_BGR2GRAY)
ret_cb, corners = cv2.findChessboardCorners(
gray, (CHESSBOARD_COLS, CHESSBOARD_ROWS),
cv2.CALIB_CB_ADAPTIVE_THRESH + cv2.CALIB_CB_NORMALIZE_IMAGE
)
if ret_cb:
corners_sub = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria)
objpoints.append(objp)
imgpoints.append(corners_sub)
print(f" 图像 {i + 1}/{len(g_captured_frames)}: 角点检测成功")
else:
print(f" 图像 {i + 1}/{len(g_captured_frames)}: 角点检测失败,跳过")
if len(objpoints) < 3:
print(f"\n错误:有效角点图像不足(需要至少3张,当前{len(objpoints)}张)!")
return
print(f"\n使用 {len(objpoints)} 张有效图像进行标定...")
img_size = gray.shape[::-1]
if distortion_free:
flags = cv2.CALIB_ZERO_TANGENT_DIST | cv2.CALIB_FIX_K1 | cv2.CALIB_FIX_K2 | cv2.CALIB_FIX_K3
print("畸变模型: 无畸变(所有畸变系数固定为 0)")
else:
flags = 0
print("畸变模型: 自由估计(k1, k2, p1, p2, k3)")
ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera(
objpoints, imgpoints, img_size, None, None, flags=flags
)
# 计算平均重投影误差
mean_error = 0
for i in range(len(objpoints)):
projected, _ = cv2.projectPoints(objpoints[i], rvecs[i], tvecs[i], mtx, dist)
error = cv2.norm(imgpoints[i], projected, cv2.NORM_L2) / len(projected)
mean_error += error
mean_error /= len(objpoints)
print("\n" + "=" * 55)
print(" 相机内参标定结果")
print("=" * 55)
print(f"\n 标定图像数: {len(objpoints)}")
print(f" 图像分辨率: {img_size[0]} x {img_size[1]}")
print(f" 方格边长: {SQUARE_SIZE} mm")
print(f"\n 内参矩阵 K (pixels):")
print(f" [{mtx[0,0]:10.4f} {mtx[0,1]:10.4f} {mtx[0,2]:10.4f}]")
print(f" [{mtx[1,0]:10.4f} {mtx[1,1]:10.4f} {mtx[1,2]:10.4f}]")
print(f" [{mtx[2,0]:10.4f} {mtx[2,1]:10.4f} {mtx[2,2]:10.4f}]")
print(f"\n 畸变系数 (k1, k2, p1, p2, k3):")
print(f" {dist.ravel()}")
print(f"\n 平均重投影误差: {mean_error:.4f} pixels")
print("=" * 55)
# 保存为 JSON 文件
save_path = "calibration_result.json"
result = {
"mtx": mtx.tolist(),
"dist": dist.tolist(),
"img_size": list(img_size),
"square_size": SQUARE_SIZE,
"error": float(mean_error),
}
with open(save_path, "w") as f:
json.dump(result, f, indent=2)
print(f"\n结果已保存到: {save_path}")
# 打印 JSON 内容,方便直接粘贴到 pnp_solve.py
print(f"\n--- 复制以下 JSON 到 pnp_solve.py 的 CALIB_JSON 变量中 ---")
print(json.dumps(result, indent=2))
print(f"----------------------------------------------------------")
return mtx, dist
if __name__ == "__main__":
print("=" * 55)
print(" 交互式相机内参标定程序")
print(f" 棋盘格: {CHESSBOARD_COLS}x{CHESSBOARD_ROWS} 内角点")
print(f" 方格边长: {SQUARE_SIZE} mm")
print("=" * 55)
print()
# 输入拍照数量
while True:
try:
n_photos = int(input_func("请输入要拍摄的标定图像数量: "))
if n_photos >= 3:
break
print("至少需要3张图像!")
except ValueError:
print("请输入有效数字!")
# 是否为无畸变镜头
while True:
ans = input_func("是否为无畸变镜头?(y/n): ").strip().lower()
if ans in ('y', 'n'):
break
print("请输入 y 或 n")
use_distortion_free = (ans == 'y')
if use_distortion_free:
print("将使用无畸变模型(畸变系数固定为 0)")
else:
print("将自由估计畸变系数")
print(f"\n将拍摄 {n_photos} 张图像。")
print("操作说明:")
print(" 1. 将棋盘格标定板以不同角度/位置放在相机视野中")
print(" 2. 看到绿色 'CHESSBOARD DETECTED' 提示后,输入 y 并回车拍照")
print(" 3. 重复直到拍完所有图像")
print()
# ===== 初始化相机 =====
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)
ret = MvCamera.MV_CC_EnumDevices(tlayerType, deviceList)
if ret != 0:
print("enum devices fail! ret[0x%x]" % ret)
sys.exit()
if deviceList.nDeviceNum == 0:
print("find no device!")
sys.exit()
print("find %d device(s)" % 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 = ''.join([chr(c) for c in mvcc_dev_info.SpecialInfo.stGigEInfo.chModelName if c != 0])
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:
print("\nu3v device: [%d]" % i)
strModeName = ''.join([chr(c) for c in mvcc_dev_info.SpecialInfo.stUsb3VInfo.chModelName if c != 0])
print("device model name: %s" % strModeName)
strSerialNumber = ''.join([chr(c) for c in mvcc_dev_info.SpecialInfo.stUsb3VInfo.chSerialNumber if c != 0])
print("user serial number: %s" % strSerialNumber)
nConnectionNum = 0
if int(nConnectionNum) >= deviceList.nDeviceNum:
print("intput error!")
sys.exit()
cam = MvCamera()
stDeviceList = cast(deviceList.pDeviceInfo[int(nConnectionNum)], POINTER(MV_CC_DEVICE_INFO)).contents
ret = cam.MV_CC_CreateHandle(stDeviceList)
if ret != 0:
print("create handle fail! ret[0x%x]" % ret)
sys.exit()
ret = cam.MV_CC_OpenDevice(MV_ACCESS_Exclusive, 0)
if ret != 0:
print("open device fail! ret[0x%x]" % ret)
sys.exit()
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)
ret = cam.MV_CC_SetEnumValue("TriggerMode", MV_TRIGGER_MODE_OFF)
if ret != 0:
print("set trigger mode fail! ret[0x%x]" % ret)
sys.exit()
ret = cam.MV_CC_RegisterImageCallBackEx2(CALL_BACK_FUN, ctypes.py_object(cam), True)
if ret != 0:
print("register image callback fail! ret[0x%x]" % ret)
sys.exit()
ret = cam.MV_CC_StartGrabbing()
if ret != 0:
print("start grabbing fail! ret[0x%x]" % ret)
sys.exit()
print("相机取流已启动。\n")
# 创建保存图像的目录
os.makedirs(g_image_dir, exist_ok=True)
print(f"标定图像将保存到: {g_image_dir}/\n")
# ===== 交互式拍照循环 =====
try:
while len(g_captured_frames) < n_photos:
remaining = n_photos - len(g_captured_frames)
cmd = input_func(f"[{len(g_captured_frames)}/{n_photos}] 输入 y 并回车拍照 (剩余 {remaining} 张): ")
if cmd.strip().lower() == 'y':
g_capture_flag = True
# 等待回调捕获(每50ms检查一次,最多等5秒)
waited = 0
while g_capture_flag and waited < 100:
cv2.waitKey(50)
waited += 1
if g_capture_flag:
print(" 警告: 超时未检测到棋盘格角点,请调整标定板位置后重试")
g_capture_flag = False
elif cmd.strip().lower() == 'q':
print("用户取消操作。")
break
else:
print(" 请输入 y (拍照) 或 q (退出)")
except KeyboardInterrupt:
print("\n用户中断。")
# ===== 停止取流 =====
print("\n停止相机取流...")
cv2.destroyAllWindows()
ret = cam.MV_CC_StopGrabbing()
if ret != 0:
print("stop grabbing fail! ret[0x%x]" % ret)
ret = cam.MV_CC_CloseDevice()
if ret != 0:
print("close device fail! ret[0x%x]" % ret)
ret = cam.MV_CC_DestroyHandle()
if ret != 0:
print("destroy handle fail! ret[0x%x]" % ret)
MvCamera.MV_CC_Finalize()
# ===== 执行标定 =====
if len(g_captured_frames) > 0:
run_calibration(distortion_free=use_distortion_free)
else:
print("\n没有捕获到图像,跳过标定。")
+532
View File
@@ -0,0 +1,532 @@
# -- coding: utf-8 --
"""
激光器-相机联合标定程序
使用方格标定板 + 激光点,拍摄多组图像,拟合相机坐标系下的激光直线方程。
流程:
1. 加载相机内参 (calibration_result.json)
2. 实时预览,棋盘格 + 激光点同时可见时按 y 拍照
3. 每张照片:PnP 解算标定板位姿 → 提取激光点像素 → 反投影求交 → 得到相机坐标系 3D 点
4. 收集所有 3D 点,PCA 拟合直线
5. 输出:方向向量 d_cam + 基准起点 O_cam (与相机 Z=0 平面的交点)
"""
import sys
import platform
import os
from ctypes import *
import numpy as np
import cv2
import json
# ===== 标定板参数(与 calibrate.py 保持一致) =====
SQUARE_SIZE = 15.0 # 方格边长 (mm)
CHESSBOARD_COLS = 11 # 内角点列数
CHESSBOARD_ROWS = 8 # 内角点行数
DETECT_INTERVAL = 3 # 棋盘格检测帧间隔
# ================================================
# ===== 激光点检测参数(蓝紫色激光,HSV 色彩空间) =====
LASER_HSV_LOWER = (100, 50, 50) # H, S, V 下限
LASER_HSV_UPPER = (160, 255, 255) # H, S, V 上限
# ===================================================
currentsystem = platform.system()
print(f"current system: {currentsystem}")
if currentsystem == 'Windows':
sys.path.append(os.getenv('MVCAM_COMMON_RUNENV') + "/Samples/Python/MvImport")
else:
sys.path.append("/opt/MVS/Samples/aarch64/Python/MvImport")
from MvCameraControl_class import *
if sys.version_info[0] < 3:
input_func = raw_input
else:
input_func = input
# ===== 全局状态 =====
g_capture_flag = False
g_laser_points_3d = [] # 收集的相机坐标系 3D 激光点
g_image_dir = "laser_calib_images"
g_frame_count = 0
g_cb_detected = False
g_cb_corners_display = None
g_laser_display = None # 激光点像素坐标(显示用)
g_K = None
g_dist = None
stFrame = POINTER(MV_FRAME_OUT)
fun_ctype = get_platform_functype()
FrameInfoCallBack2 = fun_ctype(None, stFrame, c_void_p, c_bool)
def IsHBPixelFormat(enPixelType=0):
if enPixelType in (PixelType_Gvsp_HB_Mono8, PixelType_Gvsp_HB_Mono10,
PixelType_Gvsp_HB_Mono10_Packed, PixelType_Gvsp_HB_Mono12,
PixelType_Gvsp_HB_Mono12_Packed, PixelType_Gvsp_HB_Mono16,
PixelType_Gvsp_HB_RGB8_Packed, PixelType_Gvsp_HB_BGR8_Packed,
PixelType_Gvsp_HB_RGBA8_Packed, PixelType_Gvsp_HB_BGRA8_Packed,
PixelType_Gvsp_HB_RGB16_Packed, PixelType_Gvsp_HB_BGR16_Packed,
PixelType_Gvsp_HB_RGBA16_Packed, PixelType_Gvsp_HB_BGRA16_Packed,
PixelType_Gvsp_HB_YUV422_Packed,
PixelType_Gvsp_HB_YUV422_YUYV_Packed,
PixelType_Gvsp_HB_BayerGR8, PixelType_Gvsp_HB_BayerRG8,
PixelType_Gvsp_HB_BayerGB8, PixelType_Gvsp_HB_BayerBG8,
PixelType_Gvsp_HB_BayerRBGG8,
PixelType_Gvsp_HB_BayerGB10, PixelType_Gvsp_HB_BayerGB10_Packed,
PixelType_Gvsp_HB_BayerBG10, PixelType_Gvsp_HB_BayerBG10_Packed,
PixelType_Gvsp_HB_BayerRG10, PixelType_Gvsp_HB_BayerRG10_Packed,
PixelType_Gvsp_HB_BayerGR10, PixelType_Gvsp_HB_BayerGR10_Packed,
PixelType_Gvsp_HB_BayerGB12, PixelType_Gvsp_HB_BayerGB12_Packed,
PixelType_Gvsp_HB_BayerBG12, PixelType_Gvsp_HB_BayerBG12_Packed,
PixelType_Gvsp_HB_BayerRG12, PixelType_Gvsp_HB_BayerRG12_Packed,
PixelType_Gvsp_HB_BayerGR12, PixelType_Gvsp_HB_BayerGR12_Packed):
return True
return False
def IsMonoPixelFormat(enPixelType=0):
if enPixelType in (PixelType_Gvsp_Mono8, PixelType_Gvsp_Mono10,
PixelType_Gvsp_Mono10_Packed, PixelType_Gvsp_Mono12,
PixelType_Gvsp_Mono12_Packed, PixelType_Gvsp_Mono14,
PixelType_Gvsp_Mono16):
return True
return False
def detect_laser_spot(bgr):
"""检测蓝紫色激光点:HSV 色彩空间过滤,返回亚像素质心坐标,以及二值 mask"""
hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
mask = cv2.inRange(hsv, LASER_HSV_LOWER, LASER_HSV_UPPER)
# 膨胀连接断裂区域
mask = cv2.dilate(mask, np.ones((3, 3), np.uint8), iterations=1)
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return None, None
largest = max(contours, key=cv2.contourArea)
M = cv2.moments(largest)
if M["m00"] == 0:
return None, None
cx = M["m10"] / M["m00"]
cy = M["m01"] / M["m00"]
return np.array([cx, cy], dtype=np.float32), mask
def inpaint_laser(gray, laser_mask):
"""用 inpainting 填补激光区域,避免激光遮盖棋盘格角点"""
if laser_mask is None:
return gray
# 膨胀 mask 留出安全边距
kernel = np.ones((7, 7), np.uint8)
mask_dilated = cv2.dilate(laser_mask, kernel, iterations=2)
return cv2.inpaint(gray, mask_dilated, 3, cv2.INPAINT_TELEA)
def backproject_to_plane(laser_px, rvec, tvec, K):
"""
将激光点像素坐标反投影到标定板平面,返回相机坐标系下的 3D 点。
标定板平面在相机坐标系中: n · (X - t) = 0, 其中 n = R[:, 2]
相机射线: P(s) = s * d, 其中 d = K^{-1} * [u, v, 1]^T
求交: s = (n · t) / (n · d)
"""
R, _ = cv2.Rodrigues(rvec)
n = R[:, 2] # 标定板法向量(相机坐标系)
t = tvec.ravel() # 标定板原点在相机坐标系的位置
d = np.linalg.inv(K) @ np.array([laser_px[0], laser_px[1], 1.0])
d = d / np.linalg.norm(d) # 归一化方向
nd = np.dot(n, d)
if abs(nd) < 1e-8:
return None # 射线与平面平行
s = np.dot(n, t) / nd
if s <= 0:
return None # 交点在相机后方
return (s * d).astype(np.float32)
def solve_pnp_for_checkerboard(gray, K, dist):
"""检测棋盘格并 PnP 解算标定板位姿"""
ret_cb, corners = cv2.findChessboardCorners(
gray, (CHESSBOARD_COLS, CHESSBOARD_ROWS),
cv2.CALIB_CB_ADAPTIVE_THRESH + cv2.CALIB_CB_NORMALIZE_IMAGE
)
if not ret_cb:
return None, None
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)
corners_sub = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria)
objp = np.zeros((CHESSBOARD_COLS * CHESSBOARD_ROWS, 3), np.float32)
objp[:, :2] = np.mgrid[0:CHESSBOARD_COLS, 0:CHESSBOARD_ROWS].T.reshape(-1, 2) * SQUARE_SIZE
success, rvec, tvec = cv2.solvePnP(objp, corners_sub, K, dist, flags=cv2.SOLVEPNP_IPPE)
if not success:
return None, None
return rvec, tvec
def fit_3d_line(points):
"""PCA 拟合空间直线,返回 (direction, centroid)"""
pts = np.array(points)
centroid = pts.mean(axis=0)
centered = pts - centroid
_, _, vh = np.linalg.svd(centered)
direction = vh[0] # 第一主成分 = 直线方向
# 确保方向指向 Z+ 半球
if direction[2] < 0:
direction = -direction
return direction, centroid
def image_callback(pstFrame, pUser, bAutoFree):
global g_capture_flag, g_laser_points_3d, g_frame_count
global g_cb_detected, g_cb_corners_display, g_laser_display
global g_K, g_dist
stFrame = cast(pstFrame, POINTER(MV_FRAME_OUT)).contents
User = ctypes.cast(pUser, ctypes.py_object).value
if not stFrame:
return
while True:
stDecodeParam = MV_CC_HB_DECODE_PARAM()
stConvertParam = MV_CC_PIXEL_CONVERT_PARAM_EX()
memset(byref(stConvertParam), 0, sizeof(stConvertParam))
result = IsHBPixelFormat(stFrame.stFrameInfo.enPixelType)
if True == result:
DecodeBufferlen = stFrame.stFrameInfo.nWidth * stFrame.stFrameInfo.nHeight * 3
DecodeBuffer = (c_ubyte * DecodeBufferlen)()
stDecodeParam.pSrcBuf = stFrame.pBufAddr
stDecodeParam.nSrcLen = stFrame.stFrameInfo.nFrameLen
stDecodeParam.pDstBuf = DecodeBuffer
stDecodeParam.nDstBufSize = DecodeBufferlen
ret = User.MV_CC_HBDecode(stDecodeParam)
if ret != 0:
print("HB Decode fail! ret[0x%x]" % ret)
break
stConvertParam.pSrcData = stDecodeParam.pDstBuf
stConvertParam.nSrcDataLen = stDecodeParam.nDstBufLen
stConvertParam.enSrcPixelType = stDecodeParam.enDstPixelType
else:
stConvertParam.pSrcData = stFrame.pBufAddr
stConvertParam.nSrcDataLen = stFrame.stFrameInfo.nFrameLen
stConvertParam.enSrcPixelType = stFrame.stFrameInfo.enPixelType
enDstPixelType = PixelType_Gvsp_Undefined
nChannelNum = 1
bMono = IsMonoPixelFormat(stConvertParam.enSrcPixelType)
if True == bMono:
enDstPixelType = PixelType_Gvsp_Mono8
nChannelNum = 1
else:
enDstPixelType = PixelType_Gvsp_BGR8_Packed
nChannelNum = 3
convertdestBufflen = nChannelNum * stFrame.stFrameInfo.nWidth * stFrame.stFrameInfo.nHeight
DstBuffer = (c_ubyte * convertdestBufflen)()
DstBufferlen = convertdestBufflen
stConvertParam.nWidth = stFrame.stFrameInfo.nWidth
stConvertParam.nHeight = stFrame.stFrameInfo.nHeight
stConvertParam.enDstPixelType = enDstPixelType
stConvertParam.pDstBuffer = DstBuffer
stConvertParam.nDstBufferSize = DstBufferlen
ret = User.MV_CC_ConvertPixelTypeEx(stConvertParam)
if ret != 0:
print("convert pixel fail! ret[0x%x]" % ret)
break
if nChannelNum == 1:
numpy_image = np.frombuffer(DstBuffer, dtype=np.ubyte, count=DstBufferlen).reshape(
stFrame.stFrameInfo.nHeight, stFrame.stFrameInfo.nWidth)
gray_full = numpy_image
display_image = cv2.cvtColor(numpy_image, cv2.COLOR_GRAY2BGR)
else:
numpy_image = np.frombuffer(DstBuffer, dtype=np.ubyte, count=DstBufferlen).reshape(
stFrame.stFrameInfo.nHeight, stFrame.stFrameInfo.nWidth, 3)
gray_full = cv2.cvtColor(numpy_image, cv2.COLOR_BGR2GRAY)
display_image = cv2.resize(numpy_image, None, fx=0.5, fy=0.5, interpolation=cv2.INTER_AREA)
# 激光点检测(彩色图,HSV 色彩空间)
if nChannelNum == 3:
laser_px, laser_mask = detect_laser_spot(numpy_image)
else:
laser_px, laser_mask = None, None
# 用 inpainting 填补激光区域后再做棋盘格检测,避免激光遮盖角点
if laser_mask is not None:
gray_inpainted = inpaint_laser(gray_full, laser_mask)
else:
gray_inpainted = gray_full
if g_frame_count % DETECT_INTERVAL == 0:
gray_disp = cv2.resize(gray_inpainted, None, fx=0.5, fy=0.5, interpolation=cv2.INTER_AREA) if nChannelNum == 3 else gray_inpainted
ret_cb, corners_disp = cv2.findChessboardCorners(
gray_disp, (CHESSBOARD_COLS, CHESSBOARD_ROWS),
cv2.CALIB_CB_ADAPTIVE_THRESH + cv2.CALIB_CB_NORMALIZE_IMAGE
)
g_cb_detected = ret_cb
if ret_cb:
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)
g_cb_corners_display = cv2.cornerSubPix(gray_disp, corners_disp, (5, 5), (-1, -1), criteria)
else:
g_cb_corners_display = None
if laser_px is not None and nChannelNum == 3:
g_laser_display = laser_px * 0.5
elif laser_px is not None:
g_laser_display = laser_px.copy()
else:
g_laser_display = None
# 拍照
if g_capture_flag:
if g_cb_detected and laser_px is not None and g_K is not None:
# PnP 使用 inpainting 后的灰度图,避免激光干扰角点提取
rvec, tvec = solve_pnp_for_checkerboard(gray_inpainted, g_K, g_dist)
if rvec is not None:
p3d = backproject_to_plane(laser_px, rvec, tvec, g_K)
if p3d is not None:
g_laser_points_3d.append(p3d)
idx = len(g_laser_points_3d)
path = os.path.join(g_image_dir, f"laser_{idx:02d}.png")
cv2.imwrite(path, numpy_image)
print(f" [OK] 第 {idx} 个激光点: "
f"像素({laser_px[0]:.1f}, {laser_px[1]:.1f}) -> "
f"3D({p3d[0]:.1f}, {p3d[1]:.1f}, {p3d[2]:.1f}) mm")
print(f" 图像已保存: {path}")
g_capture_flag = False
else:
print(" 警告: 激光点反投影失败")
g_capture_flag = False
else:
print(" 警告: PnP 解算失败")
g_capture_flag = False
else:
pass # 等待检测到棋盘格和激光点
# 绘制
if g_cb_detected and g_cb_corners_display is not None:
cv2.drawChessboardCorners(display_image, (CHESSBOARD_COLS, CHESSBOARD_ROWS),
g_cb_corners_display, True)
cv2.putText(display_image, "CHESSBOARD DETECTED", (10, 30),
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
if g_laser_display is not None:
lx, ly = int(g_laser_display[0]), int(g_laser_display[1])
cv2.circle(display_image, (lx, ly), 2, (0, 0, 255), -1)
cv2.circle(display_image, (lx, ly), 12, (0, 0, 255), 2)
cv2.putText(display_image, "LASER", (lx + 15, ly - 10),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2)
status = f"Points: {len(g_laser_points_3d)}"
cv2.putText(display_image, status, (10, display_image.shape[0] - 10),
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2)
cv2.imshow('Laser Calibration', display_image)
cv2.waitKey(1)
g_frame_count += 1
break
if bAutoFree == False:
if pUser != None:
User.MV_CC_FreeImageBuffer(stFrame)
CALL_BACK_FUN = FrameInfoCallBack2(image_callback)
def run_fitting():
"""拟合激光直线并输出结果"""
if len(g_laser_points_3d) < 3:
print(f"错误:激光点数量不足(需要至少3个,当前{len(g_laser_points_3d)}个)!")
return None, None
direction, centroid = fit_3d_line(g_laser_points_3d)
# 计算与相机 Z=0 平面的交点作为参考原点
if abs(direction[2]) > 1e-8:
t_z0 = -centroid[2] / direction[2]
origin = centroid + t_z0 * direction
else:
origin = centroid.copy()
print("警告:激光方向近似平行于相机 Z=0 平面,使用质心作为参考原点")
# 计算拟合残差
centered = np.array(g_laser_points_3d) - centroid
residuals = np.linalg.norm(centered - np.outer(np.dot(centered, direction), direction), axis=1)
rms = np.sqrt(np.mean(residuals ** 2))
print("\n" + "=" * 55)
print(" 激光直线标定结果")
print("=" * 55)
print(f"\n 采集点数: {len(g_laser_points_3d)}")
print(f" RMS 拟合残差: {rms:.3f} mm")
print(f"\n 直线参数 (相机坐标系):")
print(f" 方向向量 d_cam: [{direction[0]:.6f}, {direction[1]:.6f}, {direction[2]:.6f}]")
print(f" 基准起点 O_cam: [{origin[0]:.2f}, {origin[1]:.2f}, {origin[2]:.2f}] mm")
print(f" (O_cam 为直线与相机 Z=0 平面的交点)")
print("=" * 55)
# 保存
result = {
"direction": direction.tolist(),
"origin": origin.tolist(),
"num_points": len(g_laser_points_3d),
"rms_error": float(rms),
"points_3d": [p.tolist() for p in g_laser_points_3d],
}
save_path = "laser_result.json"
with open(save_path, "w") as f:
json.dump(result, f, indent=2)
print(f"\n结果已保存到: {save_path}")
# 打印可粘贴到 laser_pnp.py 的 JSON
paste = {"direction": direction.tolist(), "origin": origin.tolist()}
print(f"\n--- 复制以下 JSON 到 laser_pnp.py 的 LASER_JSON 变量中 ---")
print(json.dumps(paste, indent=2))
print(f"---------------------------------------------------------")
return direction, origin
if __name__ == "__main__":
# 加载内参
calib_path = "calibration_result.json"
try:
with open(calib_path, "r") as f:
calib_data = json.load(f)
g_K = np.array(calib_data["mtx"])
g_dist = np.array(calib_data["dist"])
img_size = tuple(calib_data["img_size"])
print(f"已加载内参: {calib_path}")
print(f" 分辨率: {img_size[0]} x {img_size[1]}")
except FileNotFoundError:
print(f"未找到标定文件: {calib_path}")
print("请先运行 calibrate.py 进行相机内参标定")
sys.exit(1)
print("=" * 55)
print(" 激光器-相机联合标定程序")
print(f" 棋盘格: {CHESSBOARD_COLS}x{CHESSBOARD_ROWS}")
print(f" 方格边长: {SQUARE_SIZE} mm")
print("=" * 55)
print()
# 输入拍照数量
while True:
try:
n_photos = int(input_func("请输入要拍摄的图像数量 (建议 >= 10): "))
if n_photos >= 3:
break
print("至少需要3张!")
except ValueError:
print("请输入有效数字!")
print(f"\n将拍摄 {n_photos} 张图像。")
print("操作说明:")
print(" 1. 将标定板以不同姿态放在相机视野中")
print(" 2. 确保激光点投射在标定板上")
print(" 3. 看到绿色棋盘格 + 红色激光点标注后,输入 y 并回车")
print()
# ===== 初始化相机 =====
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)
ret = MvCamera.MV_CC_EnumDevices(tlayerType, deviceList)
if ret != 0:
print("enum devices fail! ret[0x%x]" % ret)
sys.exit()
if deviceList.nDeviceNum == 0:
print("find no device!")
sys.exit()
print("find %d device(s)" % deviceList.nDeviceNum)
nConnectionNum = 0
cam = MvCamera()
stDeviceList = cast(deviceList.pDeviceInfo[int(nConnectionNum)], POINTER(MV_CC_DEVICE_INFO)).contents
ret = cam.MV_CC_CreateHandle(stDeviceList)
if ret != 0:
print("create handle fail! ret[0x%x]" % ret)
sys.exit()
ret = cam.MV_CC_OpenDevice(MV_ACCESS_Exclusive, 0)
if ret != 0:
print("open device fail! ret[0x%x]" % ret)
sys.exit()
if stDeviceList.nTLayerType == MV_GIGE_DEVICE or stDeviceList.nTLayerType == MV_GENTL_GIGE_DEVICE:
nPacketSize = cam.MV_CC_GetOptimalPacketSize()
if int(nPacketSize) > 0:
cam.MV_CC_SetIntValue("GevSCPSPacketSize", nPacketSize)
cam.MV_CC_SetEnumValue("TriggerMode", MV_TRIGGER_MODE_OFF)
ret = cam.MV_CC_RegisterImageCallBackEx2(CALL_BACK_FUN, ctypes.py_object(cam), True)
if ret != 0:
print("register image callback fail! ret[0x%x]" % ret)
sys.exit()
ret = cam.MV_CC_StartGrabbing()
if ret != 0:
print("start grabbing fail! ret[0x%x]" % ret)
sys.exit()
print("相机取流已启动。\n")
os.makedirs(g_image_dir, exist_ok=True)
print(f"标定图像将保存到: {g_image_dir}/\n")
# ===== 交互式拍照 =====
try:
while len(g_laser_points_3d) < n_photos:
remaining = n_photos - len(g_laser_points_3d)
cmd = input_func(f"[{len(g_laser_points_3d)}/{n_photos}] 输入 y 并回车拍照 (剩余 {remaining}): ")
if cmd.strip().lower() == 'y':
g_capture_flag = True
waited = 0
while g_capture_flag and waited < 100:
cv2.waitKey(50)
waited += 1
if g_capture_flag:
print(" 警告: 超时,请确保棋盘格和激光点同时可见")
g_capture_flag = False
elif cmd.strip().lower() == 'q':
print("用户取消。")
break
else:
print(" 请输入 y (拍照) 或 q (退出)")
except KeyboardInterrupt:
print("\n用户中断。")
# ===== 停止取流 =====
print("\n停止相机取流...")
cv2.destroyAllWindows()
cam.MV_CC_StopGrabbing()
cam.MV_CC_CloseDevice()
cam.MV_CC_DestroyHandle()
MvCamera.MV_CC_Finalize()
# ===== 拟合激光直线 =====
if len(g_laser_points_3d) > 0:
run_fitting()
else:
print("\n没有采集到激光点,跳过拟合。")
+569
View File
@@ -0,0 +1,569 @@
# -- coding: utf-8 --
"""
PnP 矩形位姿解算 + 激光点世界坐标解算
从相机画面中检测矩形(4个角点),PnP 解算相机位姿,
同时提取激光点并计算其在世界坐标系(Z=0 平面)上的位置。
"""
import sys
import platform
import os
from ctypes import *
import numpy as np
import cv2
import json
# ===== 标定结果(从 calibrate.py 生成的 JSON 粘贴到这里) =====
CALIB_JSON = """
{
"mtx": [
[
1247.0565667653996,
0.0,
753.8809622002652
],
[
0.0,
1256.6168638051222,
528.9954978706462
],
[
0.0,
0.0,
1.0
]
],
"dist": [
[
0.0,
0.0,
0.0,
0.0,
0.0
]
],
"img_size": [
1440,
1080
],
"square_size": 15.0,
"error": 0.021690239575344137
}
"""
# =============================================================
# ===== 激光直线参数(从 laser_calibrate.py 生成的 JSON 粘贴到这里) =====
LASER_JSON = """
{
"direction": [
-0.00989344622939825,
0.015559548512101173,
0.9998300075531006
],
"origin": [
-26.1895751953125,
-3.379796028137207,
0.0
]
}
"""
# ==================================================================
# ===== 矩形物理尺寸 (mm) =====
RECT_L = 305.0 # 长边 (较长的那条)
RECT_W = 215.0 # 宽边 (较短的那条)
# =============================
# ===== 图像缩放因子 =====
IMAGE_SCALE = 0.5
# ========================
# ===== 图像预处理参数 =====
BLUR_KERNEL = (5, 5)
MORPH_KERNEL = np.ones((3, 3), np.uint8)
MIN_AREA_RATIO = 0.01
# ===========================
currentsystem = platform.system()
if currentsystem == 'Windows':
sys.path.append(os.getenv('MVCAM_COMMON_RUNENV') + "/Samples/Python/MvImport")
else:
sys.path.append("/opt/MVS/Samples/aarch64/Python/MvImport")
from MvCameraControl_class import *
if sys.version_info[0] < 3:
input_func = raw_input
else:
input_func = input
# ===== 全局状态 =====
g_K = None
g_dist = None
g_d_cam = None # 激光方向向量(相机坐标系)
g_O_cam = None # 激光基准起点(相机坐标系)
def parse_calibration():
data = json.loads(CALIB_JSON)
mtx = np.array(data["mtx"])
dist = np.array(data["dist"])
img_size = tuple(data["img_size"])
print(f"已加载标定结果:")
print(f" 标定分辨率: {img_size[0]} x {img_size[1]}")
print(f" 内参矩阵:\n{mtx}")
return mtx, dist
def parse_laser():
data = json.loads(LASER_JSON)
d = np.array(data["direction"], dtype=np.float64)
o = np.array(data["origin"], dtype=np.float64)
print(f"已加载激光直线参数:")
print(f" 方向向量 d_cam: {d}")
print(f" 基准起点 O_cam: {o}")
return d, o
def detect_rectangle(gray, image_area):
h, w = gray.shape[:2]
min_area = image_area * MIN_AREA_RATIO
blurred = cv2.GaussianBlur(gray, BLUR_KERNEL, 1)
_, binary = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
closed = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, MORPH_KERNEL)
contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
best = None
best_area = 0
for cnt in contours:
area = cv2.contourArea(cnt)
if area < min_area:
continue
epsilon = 0.02 * cv2.arcLength(cnt, True)
approx = cv2.approxPolyDP(cnt, epsilon, True)
if len(approx) == 4 and cv2.isContourConvex(approx):
if area > best_area:
best_area = area
best = approx.reshape(4, 2)
return best.astype(np.float32) if best is not None else None
def order_rectangle_corners(image_corners):
pts = image_corners.copy()
edge_info = []
for i in range(4):
j = (i + 1) % 4
edge_info.append((i, j, np.linalg.norm(pts[i] - pts[j])))
edge_info.sort(key=lambda e: e[2])
short_a, short_b = edge_info[0], edge_info[1]
def mid_x(edge):
i, j, _ = edge
return (pts[i][0] + pts[j][0]) / 2.0
if mid_x(short_a) < mid_x(short_b):
left_edge, right_edge = short_a, short_b
else:
left_edge, right_edge = short_b, short_a
li, lj, _ = left_edge
if pts[li][1] < pts[lj][1]:
TL_idx, BL_idx = li, lj
else:
TL_idx, BL_idx = lj, li
ri, rj, _ = right_edge
if pts[ri][1] < pts[rj][1]:
TR_idx, BR_idx = ri, rj
else:
TR_idx, BR_idx = rj, ri
ordered = pts[[TL_idx, BL_idx, BR_idx, TR_idx]]
world = np.array([
[0, 0],
[0, RECT_W],
[RECT_L, RECT_W],
[RECT_L, 0],
], dtype=np.float32)
return ordered, world
def compute_laser_world_and_px(rvec, tvec, K, dist, d_cam, O_cam):
"""
将激光直线从相机坐标系变换到世界坐标系,求与 Z=0 平面的交点,
并投影回图像坐标用于可视化。
步骤:
1. 从 PnP 结果提取世界转相机的关系: X_cam = R * X_world + t
逆变换: X_world = R^T * (X_cam - t)
2. 方向向量(仅受旋转影响): d_world = R^T * d_cam
3. 基准起点(受旋转+平移): O_world = R^T * (O_cam - t)
4. 射线 L(s) = O_world + s * d_world
5. 令 Z=0,解 s = -O_world[2] / d_world[2],代入求 X, Y
返回: (x, y) 激光点在 Z=0 平面上的世界坐标,或 None
"""
R, _ = cv2.Rodrigues(rvec)
R_inv = R.T # 即 R^T
t = tvec.ravel()
d_world = R_inv @ d_cam
O_world = R_inv @ (O_cam - t)
if abs(d_world[2]) < 1e-10:
return None, None # 激光平行于 Z=0 平面
s = -O_world[2] / d_world[2]
P_world = O_world + s * d_world
laser_world = P_world[:2] # (x, y)
# 投影回图像坐标
P_world_3d = np.array([[P_world[0], P_world[1], 0]], dtype=np.float32)
px_proj, _ = cv2.projectPoints(P_world_3d, rvec, tvec, K, dist)
laser_px = px_proj.ravel()
return laser_world, laser_px
def draw_results(image, ordered_corners, rvec, tvec, K, dist,
laser_px=None, laser_world=None):
display = image.copy()
h = display.shape[0]
labels = ["TL", "BL", "BR", "TR"]
colors = [(0, 0, 255), (255, 0, 0), (0, 255, 0), (255, 255, 0)]
for i, (pt, label) in enumerate(zip(ordered_corners, labels)):
px, py = int(pt[0]), int(pt[1])
cv2.circle(display, (px, py), 6, colors[i], -1)
cv2.putText(display, f"{label} ({px},{py})", (px + 10, py - 10),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, colors[i], 2)
for i in range(4):
j = (i + 1) % 4
p1 = tuple(ordered_corners[i].astype(int))
p2 = tuple(ordered_corners[j].astype(int))
cv2.line(display, p1, p2, (255, 255, 255), 2)
# 绘制激光点
if laser_px is not None:
lx, ly = int(laser_px[0]), int(laser_px[1])
cv2.circle(display, (lx, ly), 2, (0, 0, 255), -1)
cv2.circle(display, (lx, ly), 10, (0, 0, 255), 2)
cv2.putText(display, "LASER", (lx + 15, ly - 10),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 2)
# 绘制坐标轴
axis_len = 50
axis_3d = np.float32([[0, 0, 0], [axis_len, 0, 0], [0, axis_len, 0], [0, 0, axis_len]])
imgpts, _ = cv2.projectPoints(axis_3d, rvec, tvec, K, dist)
origin = tuple(imgpts[0].ravel().astype(int))
cv2.line(display, origin, tuple(imgpts[1].ravel().astype(int)), (0, 0, 255), 3)
cv2.line(display, origin, tuple(imgpts[2].ravel().astype(int)), (0, 255, 0), 3)
cv2.line(display, origin, tuple(imgpts[3].ravel().astype(int)), (255, 0, 0), 3)
# 位姿文本
if rvec is not None:
t = tvec.ravel()
r, _ = cv2.Rodrigues(rvec)
sy = np.sqrt(r[0, 0] ** 2 + r[1, 0] ** 2)
if sy > 1e-6:
rx = np.arctan2(r[2, 1], r[2, 2])
ry = np.arctan2(-r[2, 0], sy)
rz = np.arctan2(r[1, 0], r[0, 0])
else:
rx = np.arctan2(-r[2, 1], r[1, 1])
ry = np.arctan2(-r[2, 0], sy)
rz = 0
lines = [
f"T: [{t[0]:.1f}, {t[1]:.1f}, {t[2]:.1f}] mm",
f"R: [{np.degrees(rx):.1f}, {np.degrees(ry):.1f}, {np.degrees(rz):.1f}] deg",
]
if laser_world is not None:
lines.append(f"Laser: ({laser_world[0]:.1f}, {laser_world[1]:.1f}) mm")
for i, text in enumerate(lines):
cv2.putText(display, text, (10, h - 10 - 25 * (2 - i)),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 255), 2)
return display
def solve_frame(frame, K, dist, d_cam, O_cam):
"""处理一帧:检测矩形 → PnP → 解算激光世界坐标 → 投影到图像"""
h, w = frame.shape[:2]
image_area = h * w
if len(frame.shape) == 3:
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
else:
gray = frame
corners = detect_rectangle(gray, image_area)
if corners is None:
return None, None, None, None, None
ordered_corners, world_pts = order_rectangle_corners(corners)
world_pts_3d = np.hstack([world_pts, np.zeros((4, 1), dtype=np.float32)])
success, rvec, tvec = cv2.solvePnP(
world_pts_3d, ordered_corners, K, dist,
flags=cv2.SOLVEPNP_IPPE
)
if not success:
return None, None, ordered_corners, None, None
# 用标定好的激光直线参数直接解算世界坐标并投影回图像
laser_world, laser_px = compute_laser_world_and_px(rvec, tvec, K, dist, d_cam, O_cam)
return rvec, tvec, ordered_corners, laser_px, laser_world
# ===== 海康相机回调 =====
stFrame = POINTER(MV_FRAME_OUT)
fun_ctype = get_platform_functype()
FrameInfoCallBack2 = fun_ctype(None, stFrame, c_void_p, c_bool)
g_frame = None
g_frame_ready = False
def IsHBPixelFormat(enPixelType=0):
if enPixelType in (PixelType_Gvsp_HB_Mono8, PixelType_Gvsp_HB_Mono10,
PixelType_Gvsp_HB_Mono10_Packed, PixelType_Gvsp_HB_Mono12,
PixelType_Gvsp_HB_Mono12_Packed, PixelType_Gvsp_HB_Mono16,
PixelType_Gvsp_HB_RGB8_Packed, PixelType_Gvsp_HB_BGR8_Packed,
PixelType_Gvsp_HB_RGBA8_Packed, PixelType_Gvsp_HB_BGRA8_Packed,
PixelType_Gvsp_HB_RGB16_Packed, PixelType_Gvsp_HB_BGR16_Packed,
PixelType_Gvsp_HB_RGBA16_Packed, PixelType_Gvsp_HB_BGRA16_Packed,
PixelType_Gvsp_HB_YUV422_Packed,
PixelType_Gvsp_HB_YUV422_YUYV_Packed,
PixelType_Gvsp_HB_BayerGR8, PixelType_Gvsp_HB_BayerRG8,
PixelType_Gvsp_HB_BayerGB8, PixelType_Gvsp_HB_BayerBG8,
PixelType_Gvsp_HB_BayerRBGG8,
PixelType_Gvsp_HB_BayerGB10, PixelType_Gvsp_HB_BayerGB10_Packed,
PixelType_Gvsp_HB_BayerBG10, PixelType_Gvsp_HB_BayerBG10_Packed,
PixelType_Gvsp_HB_BayerRG10, PixelType_Gvsp_HB_BayerRG10_Packed,
PixelType_Gvsp_HB_BayerGR10, PixelType_Gvsp_HB_BayerGR10_Packed,
PixelType_Gvsp_HB_BayerGB12, PixelType_Gvsp_HB_BayerGB12_Packed,
PixelType_Gvsp_HB_BayerBG12, PixelType_Gvsp_HB_BayerBG12_Packed,
PixelType_Gvsp_HB_BayerRG12, PixelType_Gvsp_HB_BayerRG12_Packed,
PixelType_Gvsp_HB_BayerGR12, PixelType_Gvsp_HB_BayerGR12_Packed):
return True
return False
def IsMonoPixelFormat(enPixelType=0):
if enPixelType in (PixelType_Gvsp_Mono8, PixelType_Gvsp_Mono10,
PixelType_Gvsp_Mono10_Packed, PixelType_Gvsp_Mono12,
PixelType_Gvsp_Mono12_Packed, PixelType_Gvsp_Mono14,
PixelType_Gvsp_Mono16):
return True
return False
def image_callback(pstFrame, pUser, bAutoFree):
global g_frame, g_frame_ready
stFrame = cast(pstFrame, POINTER(MV_FRAME_OUT)).contents
User = ctypes.cast(pUser, ctypes.py_object).value
if not stFrame:
return
while True:
stDecodeParam = MV_CC_HB_DECODE_PARAM()
stConvertParam = MV_CC_PIXEL_CONVERT_PARAM_EX()
memset(byref(stConvertParam), 0, sizeof(stConvertParam))
result = IsHBPixelFormat(stFrame.stFrameInfo.enPixelType)
if True == result:
DecodeBufferlen = stFrame.stFrameInfo.nWidth * stFrame.stFrameInfo.nHeight * 3
DecodeBuffer = (c_ubyte * DecodeBufferlen)()
stDecodeParam.pSrcBuf = stFrame.pBufAddr
stDecodeParam.nSrcLen = stFrame.stFrameInfo.nFrameLen
stDecodeParam.pDstBuf = DecodeBuffer
stDecodeParam.nDstBufSize = DecodeBufferlen
ret = User.MV_CC_HBDecode(stDecodeParam)
if ret != 0:
break
stConvertParam.pSrcData = stDecodeParam.pDstBuf
stConvertParam.nSrcDataLen = stDecodeParam.nDstBufLen
stConvertParam.enSrcPixelType = stDecodeParam.enDstPixelType
else:
stConvertParam.pSrcData = stFrame.pBufAddr
stConvertParam.nSrcDataLen = stFrame.stFrameInfo.nFrameLen
stConvertParam.enSrcPixelType = stFrame.stFrameInfo.enPixelType
enDstPixelType = PixelType_Gvsp_Undefined
nChannelNum = 1
bMono = IsMonoPixelFormat(stConvertParam.enSrcPixelType)
if True == bMono:
enDstPixelType = PixelType_Gvsp_Mono8
nChannelNum = 1
else:
enDstPixelType = PixelType_Gvsp_BGR8_Packed
nChannelNum = 3
convertdestBufflen = nChannelNum * stFrame.stFrameInfo.nWidth * stFrame.stFrameInfo.nHeight
DstBuffer = (c_ubyte * convertdestBufflen)()
DstBufferlen = convertdestBufflen
stConvertParam.nWidth = stFrame.stFrameInfo.nWidth
stConvertParam.nHeight = stFrame.stFrameInfo.nHeight
stConvertParam.enDstPixelType = enDstPixelType
stConvertParam.pDstBuffer = DstBuffer
stConvertParam.nDstBufferSize = DstBufferlen
ret = User.MV_CC_ConvertPixelTypeEx(stConvertParam)
if ret != 0:
break
if nChannelNum == 1:
g_frame = np.frombuffer(DstBuffer, dtype=np.ubyte, count=DstBufferlen).reshape(
stFrame.stFrameInfo.nHeight, stFrame.stFrameInfo.nWidth)
else:
g_frame = np.frombuffer(DstBuffer, dtype=np.ubyte, count=DstBufferlen).reshape(
stFrame.stFrameInfo.nHeight, stFrame.stFrameInfo.nWidth, 3)
g_frame_ready = True
break
if bAutoFree == False:
if pUser != None:
User.MV_CC_FreeImageBuffer(stFrame)
CALL_BACK_FUN = FrameInfoCallBack2(image_callback)
def init_camera():
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)
ret = MvCamera.MV_CC_EnumDevices(tlayerType, deviceList)
if ret != 0:
print("enum devices fail! ret[0x%x]" % ret)
sys.exit()
if deviceList.nDeviceNum == 0:
print("find no device!")
sys.exit()
print("find %d device(s)" % deviceList.nDeviceNum)
nConnectionNum = 0
cam = MvCamera()
stDeviceList = cast(deviceList.pDeviceInfo[int(nConnectionNum)], POINTER(MV_CC_DEVICE_INFO)).contents
ret = cam.MV_CC_CreateHandle(stDeviceList)
if ret != 0:
print("create handle fail! ret[0x%x]" % ret)
sys.exit()
ret = cam.MV_CC_OpenDevice(MV_ACCESS_Exclusive, 0)
if ret != 0:
print("open device fail! ret[0x%x]" % ret)
sys.exit()
if stDeviceList.nTLayerType == MV_GIGE_DEVICE or stDeviceList.nTLayerType == MV_GENTL_GIGE_DEVICE:
nPacketSize = cam.MV_CC_GetOptimalPacketSize()
if int(nPacketSize) > 0:
cam.MV_CC_SetIntValue("GevSCPSPacketSize", nPacketSize)
cam.MV_CC_SetEnumValue("TriggerMode", MV_TRIGGER_MODE_OFF)
ret = cam.MV_CC_RegisterImageCallBackEx2(CALL_BACK_FUN, ctypes.py_object(cam), True)
if ret != 0:
print("register image callback fail! ret[0x%x]" % ret)
sys.exit()
ret = cam.MV_CC_StartGrabbing()
if ret != 0:
print("start grabbing fail! ret[0x%x]" % ret)
sys.exit()
return cam
def close_camera(cam):
cv2.destroyAllWindows()
cam.MV_CC_StopGrabbing()
cam.MV_CC_CloseDevice()
cam.MV_CC_DestroyHandle()
MvCamera.MV_CC_Finalize()
def run_live():
global g_frame, g_frame_ready, g_K, g_dist, g_d_cam, g_O_cam
cam = init_camera()
print("相机取流已启动。按 q 退出。\n")
while not g_frame_ready:
cv2.waitKey(50)
K_use = g_K.copy()
K_use[0, 0] *= IMAGE_SCALE
K_use[1, 1] *= IMAGE_SCALE
K_use[0, 2] *= IMAGE_SCALE
K_use[1, 2] *= IMAGE_SCALE
try:
while True:
if g_frame_ready and g_frame is not None:
frame = g_frame.copy()
g_frame_ready = False
if IMAGE_SCALE != 1.0:
frame = cv2.resize(frame, None, fx=IMAGE_SCALE, fy=IMAGE_SCALE,
interpolation=cv2.INTER_AREA)
rvec, tvec, ordered, laser_px, laser_world = solve_frame(
frame, K_use, g_dist, g_d_cam, g_O_cam)
if rvec is not None:
result = draw_results(frame, ordered, rvec, tvec, K_use, g_dist,
laser_px, laser_world)
status = "TRACKING"
else:
result = frame
status = "NO RECTANGLE"
cv2.putText(result, status, (10, 30),
cv2.FONT_HERSHEY_SIMPLEX, 1,
(0, 255, 0) if rvec is not None else (0, 0, 255), 2)
cv2.imshow("Laser PnP Solver", result)
key = cv2.waitKey(1) & 0xFF
if key == ord('q'):
break
except KeyboardInterrupt:
print("\n用户中断。")
close_camera(cam)
if __name__ == "__main__":
if RECT_W > RECT_L:
RECT_L, RECT_W = RECT_W, RECT_L
print(f"矩形尺寸: 长={RECT_L} mm, 宽={RECT_W} mm")
g_K, g_dist = parse_calibration()
g_d_cam, g_O_cam = parse_laser()
run_live()
+539
View File
@@ -0,0 +1,539 @@
# -- coding: utf-8 --
"""
PnP 矩形位姿解算
从相机画面中检测矩形(4个角点),利用标定好的内参矩阵,通过 PnP 解算矩形在相机坐标系下的位姿。
"""
import sys
import platform
import os
from ctypes import *
import numpy as np
import cv2
import json
# ===== 标定结果(从 calibrate.py 生成的 JSON 粘贴到这里) =====
CALIB_JSON = """
{
"mtx": [
[
1247.0565667653996,
0.0,
753.8809622002652
],
[
0.0,
1256.6168638051222,
528.9954978706462
],
[
0.0,
0.0,
1.0
]
],
"dist": [
[
0.0,
0.0,
0.0,
0.0,
0.0
]
],
"img_size": [
1440,
1080
],
"square_size": 15.0,
"error": 0.021690239575344137
}
"""
# =============================================================
# ===== 矩形物理尺寸 (mm) =====
RECT_L = 305.0 # 长边 (较长的那条)
RECT_W = 215.0 # 宽边 (较短的那条)
# =============================
# ===== 图像缩放因子 =====
IMAGE_SCALE = 0.5 # 使用图像相对标定原图的缩放比,K 矩阵自动等比缩放
# ========================
# ===== 图像预处理参数 =====
BLUR_KERNEL = (5, 5)
MORPH_KERNEL = np.ones((3, 3), np.uint8)
MIN_AREA_RATIO = 0.01 # 矩形最小面积占画面比例
# ===========================
currentsystem = platform.system()
if currentsystem == 'Windows':
sys.path.append(os.getenv('MVCAM_COMMON_RUNENV') + "/Samples/Python/MvImport")
else:
sys.path.append("/opt/MVS/Samples/aarch64/Python/MvImport")
from MvCameraControl_class import *
if sys.version_info[0] < 3:
input_func = raw_input
else:
input_func = input
# ===== 全局状态 =====
g_K = None # 内参矩阵
g_dist = None # 畸变系数
def parse_calibration():
"""从 CALIB_JSON 常量解析标定结果"""
data = json.loads(CALIB_JSON)
mtx = np.array(data["mtx"])
dist = np.array(data["dist"])
img_size = tuple(data["img_size"])
print(f"已加载标定结果:")
print(f" 标定分辨率: {img_size[0]} x {img_size[1]}")
print(f" 内参矩阵:\n{mtx}")
print(f" 畸变系数: {dist.ravel()}")
return mtx, dist
def detect_rectangle(gray, image_area):
"""
检测图像中的矩形,返回四个角点。
复用 run.py 的预处理 + 轮廓检测流程。
返回: corners (4,2) 或 None
"""
h, w = gray.shape[:2]
min_area = image_area * MIN_AREA_RATIO
blurred = cv2.GaussianBlur(gray, BLUR_KERNEL, 1)
_, binary = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
closed = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, MORPH_KERNEL)
contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
best = None
best_area = 0
for cnt in contours:
area = cv2.contourArea(cnt)
if area < min_area:
continue
epsilon = 0.02 * cv2.arcLength(cnt, True)
approx = cv2.approxPolyDP(cnt, epsilon, True)
if len(approx) == 4 and cv2.isContourConvex(approx):
if area > best_area:
best_area = area
best = approx.reshape(4, 2)
return best.astype(np.float32) if best is not None else None
def order_rectangle_corners(image_corners):
"""
将检测到的 4 个图像角点排序,利用长宽约束识别各角点身份。
逻辑:
1. 计算 4 条边的长度,找出 2 条短边
2. 比较两条短边中点的 x 坐标,x 较小者为左短边,另一条为右短边
3. 左短边上 y 较小的端点为 TL,较大的为 BL
4. 右短边上 y 较小的端点为 TR,较大的为 BR
5. 按逆时针顺序返回: [TL, BL, BR, TR]
返回: (ordered_corners, world_points)
ordered_corners: [TL, BL, BR, TR] 图像坐标
world_points: [TL, BL, BR, TR] 世界坐标
"""
pts = image_corners.copy()
# 1. 计算 4 条边(approxPolyDP 返回的点是首尾相连的轮廓顺序)
edge_info = [] # [(i, j, length), ...]
for i in range(4):
j = (i + 1) % 4
edge_info.append((i, j, np.linalg.norm(pts[i] - pts[j])))
# 2. 按边长排序,找出两条短边
edge_info.sort(key=lambda e: e[2])
short_a, short_b = edge_info[0], edge_info[1] # 两条短边
# 3. 比较中点 x 坐标,确定左短边和右短边
def mid_x(edge):
i, j, _ = edge
return (pts[i][0] + pts[j][0]) / 2.0
if mid_x(short_a) < mid_x(short_b):
left_edge = short_a
right_edge = short_b
else:
left_edge = short_b
right_edge = short_a
# 4. 左短边上 y 较小的是 TL,较大的是 BL
li, lj, _ = left_edge
if pts[li][1] < pts[lj][1]:
TL_idx, BL_idx = li, lj
else:
TL_idx, BL_idx = lj, li
# 5. 右短边上 y 较小的是 TR,较大的是 BR
ri, rj, _ = right_edge
if pts[ri][1] < pts[rj][1]:
TR_idx, BR_idx = ri, rj
else:
TR_idx, BR_idx = rj, ri
# 逆时针: TL -> BL -> BR -> TR
ordered = pts[[TL_idx, BL_idx, BR_idx, TR_idx]]
# 世界坐标,与角点顺序一一对应
world = np.array([
[0, 0], # TL
[0, RECT_W], # BL
[RECT_L, RECT_W], # BR
[RECT_L, 0], # TR
], dtype=np.float32)
return ordered, world
def draw_results(image, ordered_corners, rvec, tvec, K, dist):
"""在图像上绘制角点标注和位姿信息"""
display = image.copy()
h, w = display.shape[:2]
labels = ["TL", "BL", "BR", "TR"]
colors = [(0, 0, 255), (255, 0, 0), (0, 255, 0), (255, 255, 0)]
for i, (pt, label) in enumerate(zip(ordered_corners, labels)):
px, py = int(pt[0]), int(pt[1])
cv2.circle(display, (px, py), 6, colors[i], -1)
cv2.putText(display, f"{label} ({px},{py})", (px + 10, py - 10),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, colors[i], 2)
# 绘制矩形边
for i in range(4):
j = (i + 1) % 4
p1 = tuple(ordered_corners[i].astype(int))
p2 = tuple(ordered_corners[j].astype(int))
cv2.line(display, p1, p2, (255, 255, 255), 2)
# 绘制坐标轴(投影到图像)
axis_len = 50 # mm
axis_3d = np.float32([[0, 0, 0], [axis_len, 0, 0], [0, axis_len, 0], [0, 0, axis_len]])
imgpts, _ = cv2.projectPoints(axis_3d, rvec, tvec, K, dist)
origin = tuple(imgpts[0].ravel().astype(int))
cv2.line(display, origin, tuple(imgpts[1].ravel().astype(int)), (0, 0, 255), 3) # X: red
cv2.line(display, origin, tuple(imgpts[2].ravel().astype(int)), (0, 255, 0), 3) # Y: green
cv2.line(display, origin, tuple(imgpts[3].ravel().astype(int)), (255, 0, 0), 3) # Z: blue
# 位姿文本
if rvec is not None:
t = tvec.ravel()
r, _ = cv2.Rodrigues(rvec)
# 欧拉角 (ZYX)
sy = np.sqrt(r[0, 0] ** 2 + r[1, 0] ** 2)
if sy > 1e-6:
rx = np.arctan2(r[2, 1], r[2, 2])
ry = np.arctan2(-r[2, 0], sy)
rz = np.arctan2(r[1, 0], r[0, 0])
else:
rx = np.arctan2(-r[2, 1], r[1, 1])
ry = np.arctan2(-r[2, 0], sy)
rz = 0
lines = [
f"T: [{t[0]:.1f}, {t[1]:.1f}, {t[2]:.1f}] mm",
f"R: [{np.degrees(rx):.1f}, {np.degrees(ry):.1f}, {np.degrees(rz):.1f}] deg",
]
for i, text in enumerate(lines):
cv2.putText(display, text, (10, h - 10 - 25 * (1 - i)),
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 255), 2)
return display
def solve_pnp(image, K, dist, debug=False):
"""
从单张图像中检测矩形并解算位姿。
返回: (rvec, tvec, ordered_corners) 或 (None, None, None)
"""
h, w = image.shape[:2]
image_area = h * w
if len(image.shape) == 3:
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
else:
gray = image
corners = detect_rectangle(gray, image_area)
if corners is None:
return None, None, None
ordered_corners, world_pts = order_rectangle_corners(corners)
# solvePnP 要求世界坐标为 Nx3(矩形在 Z=0 平面上)
world_pts_3d = np.hstack([world_pts, np.zeros((4, 1), dtype=np.float32)])
success, rvec, tvec = cv2.solvePnP(
world_pts_3d, ordered_corners, K, dist,
flags=cv2.SOLVEPNP_IPPE
)
if not success:
return None, None, None
if debug:
print(f" 平移向量 (mm): {tvec.ravel()}")
print(f" 旋转向量: {rvec.ravel()}")
return rvec, tvec, ordered_corners
# ===== 海康相机相关函数 =====
stFrame = POINTER(MV_FRAME_OUT)
fun_ctype = get_platform_functype()
FrameInfoCallBack2 = fun_ctype(None, stFrame, c_void_p, c_bool)
g_frame = None
g_frame_ready = False
def IsHBPixelFormat(enPixelType=0):
if enPixelType in (PixelType_Gvsp_HB_Mono8, PixelType_Gvsp_HB_Mono10,
PixelType_Gvsp_HB_Mono10_Packed, PixelType_Gvsp_HB_Mono12,
PixelType_Gvsp_HB_Mono12_Packed, PixelType_Gvsp_HB_Mono16,
PixelType_Gvsp_HB_RGB8_Packed, PixelType_Gvsp_HB_BGR8_Packed,
PixelType_Gvsp_HB_RGBA8_Packed, PixelType_Gvsp_HB_BGRA8_Packed,
PixelType_Gvsp_HB_RGB16_Packed, PixelType_Gvsp_HB_BGR16_Packed,
PixelType_Gvsp_HB_RGBA16_Packed, PixelType_Gvsp_HB_BGRA16_Packed,
PixelType_Gvsp_HB_YUV422_Packed, PixelType_Gvsp_HB_YUV422_YUYV_Packed,
PixelType_Gvsp_HB_BayerGR8, PixelType_Gvsp_HB_BayerRG8,
PixelType_Gvsp_HB_BayerGB8, PixelType_Gvsp_HB_BayerBG8,
PixelType_Gvsp_HB_BayerRBGG8,
PixelType_Gvsp_HB_BayerGB10, PixelType_Gvsp_HB_BayerGB10_Packed,
PixelType_Gvsp_HB_BayerBG10, PixelType_Gvsp_HB_BayerBG10_Packed,
PixelType_Gvsp_HB_BayerRG10, PixelType_Gvsp_HB_BayerRG10_Packed,
PixelType_Gvsp_HB_BayerGR10, PixelType_Gvsp_HB_BayerGR10_Packed,
PixelType_Gvsp_HB_BayerGB12, PixelType_Gvsp_HB_BayerGB12_Packed,
PixelType_Gvsp_HB_BayerBG12, PixelType_Gvsp_HB_BayerBG12_Packed,
PixelType_Gvsp_HB_BayerRG12, PixelType_Gvsp_HB_BayerRG12_Packed,
PixelType_Gvsp_HB_BayerGR12, PixelType_Gvsp_HB_BayerGR12_Packed):
return True
return False
def IsMonoPixelFormat(enPixelType=0):
if enPixelType in (PixelType_Gvsp_Mono8, PixelType_Gvsp_Mono10,
PixelType_Gvsp_Mono10_Packed, PixelType_Gvsp_Mono12,
PixelType_Gvsp_Mono12_Packed, PixelType_Gvsp_Mono14,
PixelType_Gvsp_Mono16):
return True
return False
def image_callback(pstFrame, pUser, bAutoFree):
global g_frame, g_frame_ready
stFrame = cast(pstFrame, POINTER(MV_FRAME_OUT)).contents
User = ctypes.cast(pUser, ctypes.py_object).value
if not stFrame:
return
while True:
stDecodeParam = MV_CC_HB_DECODE_PARAM()
stConvertParam = MV_CC_PIXEL_CONVERT_PARAM_EX()
memset(byref(stConvertParam), 0, sizeof(stConvertParam))
result = IsHBPixelFormat(stFrame.stFrameInfo.enPixelType)
if True == result:
DecodeBufferlen = stFrame.stFrameInfo.nWidth * stFrame.stFrameInfo.nHeight * 3
DecodeBuffer = (c_ubyte * DecodeBufferlen)()
stDecodeParam.pSrcBuf = stFrame.pBufAddr
stDecodeParam.nSrcLen = stFrame.stFrameInfo.nFrameLen
stDecodeParam.pDstBuf = DecodeBuffer
stDecodeParam.nDstBufSize = DecodeBufferlen
ret = User.MV_CC_HBDecode(stDecodeParam)
if ret != 0:
break
stConvertParam.pSrcData = stDecodeParam.pDstBuf
stConvertParam.nSrcDataLen = stDecodeParam.nDstBufLen
stConvertParam.enSrcPixelType = stDecodeParam.enDstPixelType
else:
stConvertParam.pSrcData = stFrame.pBufAddr
stConvertParam.nSrcDataLen = stFrame.stFrameInfo.nFrameLen
stConvertParam.enSrcPixelType = stFrame.stFrameInfo.enPixelType
enDstPixelType = PixelType_Gvsp_Undefined
nChannelNum = 1
bMono = IsMonoPixelFormat(stConvertParam.enSrcPixelType)
if True == bMono:
enDstPixelType = PixelType_Gvsp_Mono8
nChannelNum = 1
else:
enDstPixelType = PixelType_Gvsp_BGR8_Packed
nChannelNum = 3
convertdestBufflen = nChannelNum * stFrame.stFrameInfo.nWidth * stFrame.stFrameInfo.nHeight
DstBuffer = (c_ubyte * convertdestBufflen)()
DstBufferlen = convertdestBufflen
stConvertParam.nWidth = stFrame.stFrameInfo.nWidth
stConvertParam.nHeight = stFrame.stFrameInfo.nHeight
stConvertParam.enDstPixelType = enDstPixelType
stConvertParam.pDstBuffer = DstBuffer
stConvertParam.nDstBufferSize = DstBufferlen
ret = User.MV_CC_ConvertPixelTypeEx(stConvertParam)
if ret != 0:
break
if nChannelNum == 1:
g_frame = np.frombuffer(DstBuffer, dtype=np.ubyte, count=DstBufferlen).reshape(
stFrame.stFrameInfo.nHeight, stFrame.stFrameInfo.nWidth)
else:
g_frame = np.frombuffer(DstBuffer, dtype=np.ubyte, count=DstBufferlen).reshape(
stFrame.stFrameInfo.nHeight, stFrame.stFrameInfo.nWidth, 3)
g_frame_ready = True
break
if bAutoFree == False:
if pUser != None:
User.MV_CC_FreeImageBuffer(stFrame)
CALL_BACK_FUN = FrameInfoCallBack2(image_callback)
def init_camera():
"""初始化海康相机,返回 cam 对象"""
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)
ret = MvCamera.MV_CC_EnumDevices(tlayerType, deviceList)
if ret != 0:
print("enum devices fail! ret[0x%x]" % ret)
sys.exit()
if deviceList.nDeviceNum == 0:
print("find no device!")
sys.exit()
print("find %d device(s)" % 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_USB_DEVICE:
strModeName = ''.join([chr(c) for c in mvcc_dev_info.SpecialInfo.stUsb3VInfo.chModelName if c != 0])
print(" [%d] %s" % (i, strModeName))
elif mvcc_dev_info.nTLayerType == MV_GIGE_DEVICE or mvcc_dev_info.nTLayerType == MV_GENTL_GIGE_DEVICE:
strModeName = ''.join([chr(c) for c in mvcc_dev_info.SpecialInfo.stGigEInfo.chModelName if c != 0])
print(" [%d] %s" % (i, strModeName))
nConnectionNum = 0
cam = MvCamera()
stDeviceList = cast(deviceList.pDeviceInfo[int(nConnectionNum)], POINTER(MV_CC_DEVICE_INFO)).contents
ret = cam.MV_CC_CreateHandle(stDeviceList)
if ret != 0:
print("create handle fail! ret[0x%x]" % ret)
sys.exit()
ret = cam.MV_CC_OpenDevice(MV_ACCESS_Exclusive, 0)
if ret != 0:
print("open device fail! ret[0x%x]" % ret)
sys.exit()
if stDeviceList.nTLayerType == MV_GIGE_DEVICE or stDeviceList.nTLayerType == MV_GENTL_GIGE_DEVICE:
nPacketSize = cam.MV_CC_GetOptimalPacketSize()
if int(nPacketSize) > 0:
cam.MV_CC_SetIntValue("GevSCPSPacketSize", nPacketSize)
cam.MV_CC_SetEnumValue("TriggerMode", MV_TRIGGER_MODE_OFF)
ret = cam.MV_CC_RegisterImageCallBackEx2(CALL_BACK_FUN, ctypes.py_object(cam), True)
if ret != 0:
print("register image callback fail! ret[0x%x]" % ret)
sys.exit()
ret = cam.MV_CC_StartGrabbing()
if ret != 0:
print("start grabbing fail! ret[0x%x]" % ret)
sys.exit()
return cam
def close_camera(cam):
cv2.destroyAllWindows()
cam.MV_CC_StopGrabbing()
cam.MV_CC_CloseDevice()
cam.MV_CC_DestroyHandle()
MvCamera.MV_CC_Finalize()
def run_live():
"""实时相机模式:持续检测矩形并解算位姿"""
global g_frame, g_frame_ready, g_K, g_dist
cam = init_camera()
print("相机取流已启动。按 q 退出。\n")
# 等待第一帧
while not g_frame_ready:
cv2.waitKey(50)
# 根据缩放因子调整内参矩阵
K_use = g_K.copy()
K_use[0, 0] *= IMAGE_SCALE # fx
K_use[1, 1] *= IMAGE_SCALE # fy
K_use[0, 2] *= IMAGE_SCALE # cx
K_use[1, 2] *= IMAGE_SCALE # cy
try:
while True:
if g_frame_ready and g_frame is not None:
frame = g_frame.copy()
g_frame_ready = False
if IMAGE_SCALE != 1.0:
frame = cv2.resize(frame, None, fx=IMAGE_SCALE, fy=IMAGE_SCALE,
interpolation=cv2.INTER_AREA)
rvec, tvec, ordered = solve_pnp(frame, K_use, g_dist)
if rvec is not None:
result = draw_results(frame, ordered, rvec, tvec, K_use, g_dist)
status = "TRACKING"
else:
result = frame
status = "NO RECTANGLE"
cv2.putText(result, status, (10, 30),
cv2.FONT_HERSHEY_SIMPLEX, 1,
(0, 255, 0) if rvec is not None else (0, 0, 255), 2)
cv2.imshow("PnP Solver", result)
key = cv2.waitKey(1) & 0xFF
if key == ord('q'):
break
except KeyboardInterrupt:
print("\n用户中断。")
close_camera(cam)
if __name__ == "__main__":
if RECT_W > RECT_L:
RECT_L, RECT_W = RECT_W, RECT_L
print(f"矩形尺寸: 长={RECT_L} mm, 宽={RECT_W} mm")
g_K, g_dist = parse_calibration()
run_live()
@@ -0,0 +1,49 @@
### 激光点位置解算详细说明
本方案旨在通过视觉PnP算法解算相机位姿,结合预先标定的“相机-激光器”刚体关系,计算激光点在矩形平面(世界坐标系)上的精确位置。
#### 1. 前置准备:相机内参标定
首先对相机进行内参标定,获取相机内参矩阵 $K$ 和畸变系数。这是后续所有像素坐标转空间坐标的基础。
#### 2. 第一阶段:激光器与相机的联合标定(离线)
由于激光器与相机刚性绑定,激光束在**相机坐标系**下表现为一条固定的空间直线。我们需要通过以下步骤求出这条直线的方程(起点与方向)。
1. **数据采集**:
- 开启激光器,将激光点投射在平面标定板(如棋盘格)上。
- 保持相机与激光器相对静止,改变整体姿态(距离、角度),拍摄多组(建议10组以上)包含标定板和激光点的图像。
2. **解算相机坐标系下的激光点**:
- **外参获取**:对每张图像使用PnP算法,根据标定板角点计算相机相对于标定板的外参矩阵 $T_{wc}$(即世界到相机的变换)。
- **反投影求交**:
- 提取激光点的像素坐标 $(u, v)$。
- 利用 $K^{-1}$ 将其转换为归一化射线方向。
- **关键步骤**:利用标定板在相机坐标系下的平面方程与该射线求交,计算出激光点在**相机坐标系**下的三维坐标 $P_{c\_i}$。
3. **拟合激光线方程**:
- 收集所有图像对应的相机坐标系下的激光点 $\{P_{c\_1}, P_{c\_2}, ...\}$。
- 使用最小二乘法或PCA(主成分分析)拟合这些点,得到相机坐标系下的激光直线方程。
- **记录参数**:
- **方向向量 $\vec{d}_{cam}$**:拟合直线的单位方向向量。
- **基准起点 $O_{cam}$**:选取该直线与相机坐标系 $Z=0$ 平面的交点作为参考原点。
#### 3. 第二阶段:在线测量与位置解算
标定完成后,即可在实际应用中实时计算激光点位置。
1. **获取当前相机位姿**:
- 拍摄包含目标矩形平面的图像。
- 根据成像原理:$\text{像素坐标} \approx K \cdot [\text{归一化平面}] (T_{wc} \cdot \text{世界坐标})$。
- 已知像素坐标、内参 $K$ 以及矩形平面的特征点世界坐标,利用PnP算法解算出当前的外参矩阵 $T_{wc}$(包含旋转矩阵 $R$ 和平移向量 $t$)。
2. **坐标变换至世界坐标系**:
将标定阶段得到的激光线参数从相机坐标系变换到世界坐标系:
- **变换方向向量**(仅受旋转影响):
$$ \vec{d}_{world} = R^T \cdot \vec{d}_{cam} $$
- **变换基准起点**(受旋转和平移共同影响,即乘以 $T_{wc}$ 的逆矩阵):
$$ O_{world} = R^T \cdot (O_{cam} - t) $$
3. **求解世界坐标**:
- 构建世界坐标系下的激光射线方程:$L(s) = O_{world} + s \cdot \vec{d}_{world}$。
- **平面约束**:已知目标平面在世界坐标系中满足 $Z_{world} = 0$。
- **联立求解**:令射线的Z分量等于0,解出参数 $s$,代回方程求得 $X, Y$。
- **最终输出**:激光点在矩形平面上的世界坐标 $(x, y, 0)$。
+397
View File
@@ -0,0 +1,397 @@
# -- coding: utf-8 --
import sys
import platform
import copy
import os
from ctypes import *
import numpy
import cv2
# 配置项
image_area = 1080 * 1440 / 4
threshold_area = image_area*0.01
currentsystem = platform.system()
print(f"current system: {currentsystem}")
if currentsystem == 'Windows':
sys.path.append(os.getenv('MVCAM_COMMON_RUNENV') + "/Samples/Python/MvImport")
else:
# Demo 目录: "/opt/xxx/Samples/平台/OpenCV/ZZ/ ") python接口目录: "/opt/xxx/Samples/平台/Python/MvImport"
sys.path.append("/opt/MVS/Samples/aarch64/Python/MvImport")
print(f"sys.path: {sys.path}")
from MvCameraControl_class import *
# 兼容Python 2.x和3.x的输入处理
if sys.version_info[0] < 3:
# Python 2.x
input_func = raw_input
else:
# Python 3.x
input_func = input
stFrame = POINTER(MV_FRAME_OUT)
fun_ctype = get_platform_functype()
FrameInfoCallBack2 = fun_ctype(None, stFrame, c_void_p, c_bool)
def IsHBPixelFormat(enPixelType = 0):
if enPixelType in (PixelType_Gvsp_HB_Mono8, \
PixelType_Gvsp_HB_Mono10,\
PixelType_Gvsp_HB_Mono10_Packed,\
PixelType_Gvsp_HB_Mono12,\
PixelType_Gvsp_HB_Mono12_Packed,\
PixelType_Gvsp_HB_Mono16,\
PixelType_Gvsp_HB_RGB8_Packed,\
PixelType_Gvsp_HB_BGR8_Packed,\
PixelType_Gvsp_HB_RGBA8_Packed,\
PixelType_Gvsp_HB_BGRA8_Packed,\
PixelType_Gvsp_HB_RGB16_Packed,\
PixelType_Gvsp_HB_BGR16_Packed,\
PixelType_Gvsp_HB_RGBA16_Packed,\
PixelType_Gvsp_HB_BGRA16_Packed,\
PixelType_Gvsp_HB_YUV422_Packed,\
PixelType_Gvsp_HB_YUV422_YUYV_Packed,\
PixelType_Gvsp_HB_BayerGR8,\
PixelType_Gvsp_HB_BayerRG8,\
PixelType_Gvsp_HB_BayerGB8,\
PixelType_Gvsp_HB_BayerBG8,\
PixelType_Gvsp_HB_BayerRBGG8,\
PixelType_Gvsp_HB_BayerGB10,\
PixelType_Gvsp_HB_BayerGB10_Packed,\
PixelType_Gvsp_HB_BayerBG10,\
PixelType_Gvsp_HB_BayerBG10_Packed,\
PixelType_Gvsp_HB_BayerRG10,\
PixelType_Gvsp_HB_BayerRG10_Packed,\
PixelType_Gvsp_HB_BayerGR10,\
PixelType_Gvsp_HB_BayerGR10_Packed,\
PixelType_Gvsp_HB_BayerGB12,\
PixelType_Gvsp_HB_BayerGB12_Packed,\
PixelType_Gvsp_HB_BayerBG12,\
PixelType_Gvsp_HB_BayerBG12_Packed,\
PixelType_Gvsp_HB_BayerRG12,\
PixelType_Gvsp_HB_BayerRG12_Packed,\
PixelType_Gvsp_HB_BayerGR12,\
PixelType_Gvsp_HB_BayerGR12_Packed):
return True
else:
return False
def IsMonoPixelFormat(enPixelType = 0):
if enPixelType in (PixelType_Gvsp_Mono8, \
PixelType_Gvsp_Mono10, \
PixelType_Gvsp_Mono10_Packed, \
PixelType_Gvsp_Mono12, \
PixelType_Gvsp_Mono12_Packed, \
PixelType_Gvsp_Mono14, \
PixelType_Gvsp_Mono16):
return True
else:
return False
#执行1次
g_SaveImageOnce = False
def image_callback2(pstFrame, pUser, bAutoFree):
start_time = cv2.getTickCount()
stFrame = cast(pstFrame, POINTER(MV_FRAME_OUT)).contents
User = ctypes.cast(pUser, ctypes.py_object).value
if stFrame:
# print ("get one frame: Width[%d], Height[%d], nFrameNum[%d] len[%d] nLostPacket[%d]" % (stFrame.stFrameInfo.nWidth, stFrame.stFrameInfo.nHeight, stFrame.stFrameInfo.nFrameNum ,stFrame.stFrameInfo.nFrameLen, stFrame.stFrameInfo.nLostPacket))
global g_SaveImageOnce
while True:
stDecodeParam = MV_CC_HB_DECODE_PARAM()
stConvertParam = MV_CC_PIXEL_CONVERT_PARAM_EX()
memset(byref(stConvertParam), 0, sizeof(stConvertParam))
# 如果是HB图像,需要进行HB解码
result = IsHBPixelFormat(stFrame.stFrameInfo.enPixelType)
if True == result :
DecodeBufferlen = stFrame.stFrameInfo.nWidth * stFrame.stFrameInfo.nHeight * 3
DecodeBuffer = (c_ubyte * DecodeBufferlen)()
stDecodeParam.pSrcBuf = stFrame.pBufAddr
stDecodeParam.nSrcLen = stFrame.stFrameInfo.nFrameLen
stDecodeParam.pDstBuf = DecodeBuffer
stDecodeParam.nDstBufSize = DecodeBufferlen
ret = User.MV_CC_HBDecode(stDecodeParam)
if ret != 0:
print("HB Decode fail! ret[0x%x]" % ret)
break
stConvertParam.pSrcData = stDecodeParam.pDstBuf
stConvertParam.nSrcDataLen = stDecodeParam.nDstBufLen
stConvertParam.enSrcPixelType = stDecodeParam.enDstPixelType
else:
stConvertParam.pSrcData = stFrame.pBufAddr
stConvertParam.nSrcDataLen = stFrame.stFrameInfo.nFrameLen
stConvertParam.enSrcPixelType = stFrame.stFrameInfo.enPixelType
# 图像进行格式转换
enDstPixelType = PixelType_Gvsp_Undefined
nChannelNum = 1
bMono = IsMonoPixelFormat(stConvertParam.enSrcPixelType)
if True == bMono :
enDstPixelType = PixelType_Gvsp_Mono8
nChannelNum = 1
else:
enDstPixelType = PixelType_Gvsp_BGR8_Packed
nChannelNum = 3
convertdestBufflen = nChannelNum *stFrame.stFrameInfo.nWidth * stFrame.stFrameInfo.nHeight
DstBuffer = (c_ubyte * convertdestBufflen)()
DstBufferlen = convertdestBufflen
stConvertParam.nWidth = stFrame.stFrameInfo.nWidth
stConvertParam.nHeight = stFrame.stFrameInfo.nHeight
stConvertParam.enDstPixelType = enDstPixelType
stConvertParam.pDstBuffer = DstBuffer
stConvertParam.nDstBufferSize = DstBufferlen
ret = User.MV_CC_ConvertPixelTypeEx(stConvertParam)
if ret != 0:
print ("convert pixel fail! ret[0x%x]" % ret)
break
if nChannelNum == 1:
numpy_image = numpy.frombuffer(DstBuffer, dtype=numpy.ubyte, count=DstBufferlen). \
reshape(stFrame.stFrameInfo.nHeight, stFrame.stFrameInfo.nWidth)
# 执行1次, 只保存1张图
if g_SaveImageOnce == False:
output_path = "output_mono_image.bmp"
cv2.imwrite(output_path, numpy_image)
print("save output_mono_image.bmp success")
g_SaveImageOnce = True
else:
numpy_image = numpy.frombuffer(DstBuffer, dtype=numpy.ubyte, count=DstBufferlen). \
reshape(stFrame.stFrameInfo.nHeight, stFrame.stFrameInfo.nWidth, 3)
#分辨率缩小一半,提高处理速度
numpy_image = cv2.resize(numpy_image, None, fx=0.5, fy=0.5, interpolation=cv2.INTER_AREA)
# 1. 预处理:灰度图 + 高斯模糊 (去噪)
Gray_image = cv2.cvtColor(numpy_image, cv2.COLOR_BGR2GRAY)
# 稍微增大模糊核,例如 (5,5),以平滑更多纹理细节
Gauss_image = cv2.GaussianBlur(Gray_image, (5, 5), 1)
# 2. 二值化替代 Canny (更稳健)
# 方法A: Otsu自动阈值 (适合对比度明显的场景)
ret, binary = cv2.threshold(Gauss_image, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
# 方法B: 如果光照不均,建议取消下面注释改用自适应阈值
# binary = cv2.adaptiveThreshold(Gauss_image, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
# cv2.THRESH_BINARY, 11, 2)
# 3. 形态学操作 (关键步骤:连接断裂,去除噪点)
kernel = numpy.ones((3,3),numpy.uint8) # 核越大,连接能力越强,但也可能使物体变形
dilated_binary = cv2.dilate(binary, kernel, iterations=1) # 膨胀
eroded_binary = cv2.erode(dilated_binary, kernel, iterations=1) # 腐蚀 (先膨胀后腐蚀=闭运算,用于填补空洞)
# 4. 查找轮廓
# 注意:二值化图像中,物体应为白色(255),背景为黑色(0)
contours, _ = cv2.findContours(eroded_binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
for cnt in contours:
cv2.drawContours(numpy_image, [cnt], -1, (0, 255, 0), 1) # 绘制原始轮廓,调试用
# --- 筛选条件 1: 面积过滤 ---
# 忽略太小的噪点,根据实际情况调整数值 (例如忽略小于画面 1% 的区域)
area = cv2.contourArea(cnt)
if area < threshold_area:
continue
# --- 筛选条件 2: 形状近似,必须是四边形且为凸多边形 ---
epsilon = 0.02 * cv2.arcLength(cnt, True)
approx = cv2.approxPolyDP(cnt, epsilon, True)
if len(approx) == 4 and cv2.isContourConvex(approx):
# 绘制结果
cv2.drawContours(numpy_image, [approx], -1, (0, 0, 255), 2)
# 绘制顶点
for point in approx:
cv2.circle(numpy_image, tuple(point[0]), 5, (255, 0, 0), -1)
# fps计算
current_time = cv2.getTickCount()
fps = cv2.getTickFrequency() / (current_time - start_time)
text = f"FPS: {fps:.2f}"
# 在左上角绘制文字
cv2.putText(numpy_image, text, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2)
# 显示结果
cv2.imshow('Result', numpy_image)
cv2.imshow('Binary', eroded_binary) # 调试时可查看二值化效果
# cv2.imshow("ROI", img_ROI)
cv2.waitKey(1)
# 执行1次, 只保存1张图
# if g_SaveImageOnce == False:
# output_path = "output_color_image.bmp"
# cv2.imwrite(output_path, numpy_image)
# print("save output_color_image.bmp success")
# g_SaveImageOnce = True
#正常退出
break
#非自动释放模式,需要额外进行 手动释放资源
if bAutoFree == False :
if pUser != None:
User.MV_CC_FreeImageBuffer(stFrame)
else :
print ("user is null, invalid")
CALL_BACK_FUN2 = FrameInfoCallBack2(image_callback2)
if __name__ == "__main__":
# ch:初始化SDK | en: initialize SDK
MvCamera.MV_CC_Initialize()
deviceList = MV_CC_DEVICE_INFO_LIST()
# ch:选择枚举的传输层协议 (根据需求和系统支持情况选择) | en: Select the Transport Layer Protocol (based on your requirements and system support).
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 ("enum devices fail! ret[0x%x]" % ret)
sys.exit()
if deviceList.nDeviceNum == 0:
print ("find no device!")
sys.exit()
print ("find %d devices!" % 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 = ''.join([chr(c) for c in mvcc_dev_info.SpecialInfo.stGigEInfo.chModelName if c != 0])
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:
print ("\nu3v device: [%d]" % i)
strModeName = ''.join([chr(c) for c in mvcc_dev_info.SpecialInfo.stUsb3VInfo.chModelName if c != 0])
print ("device model name: %s" % strModeName)
strSerialNumber = ''.join([chr(c) for c in mvcc_dev_info.SpecialInfo.stUsb3VInfo.chSerialNumber if c != 0])
print ("user serial number: %s" % strSerialNumber)
elif mvcc_dev_info.nTLayerType == MV_GENTL_CAMERALINK_DEVICE:
print ("\nCML device: [%d]" % i)
strModeName = ''.join([chr(c) for c in mvcc_dev_info.SpecialInfo.stCMLInfo.chModelName if c != 0])
print ("device model name: %s" % strModeName)
strSerialNumber = ''.join([chr(c) for c in mvcc_dev_info.SpecialInfo.stCMLInfo.chSerialNumber if c != 0])
print ("user serial number: %s" % strSerialNumber)
elif mvcc_dev_info.nTLayerType == MV_GENTL_CXP_DEVICE:
print ("\nCXP device: [%d]" % i)
strModeName = ''.join([chr(c) for c in mvcc_dev_info.SpecialInfo.stCXPInfo.chModelName if c != 0])
print ("device model name: %s" % strModeName)
strSerialNumber = ''.join([chr(c) for c in mvcc_dev_info.SpecialInfo.stCXPInfo.chSerialNumber if c != 0])
print ("user serial number: %s" % strSerialNumber)
elif mvcc_dev_info.nTLayerType == MV_GENTL_XOF_DEVICE:
print ("\nXoF device: [%d]" % i)
strModeName = ''.join([chr(c) for c in mvcc_dev_info.SpecialInfo.stXoFInfo.chModelName if c != 0])
print ("device model name: %s" % strModeName)
strSerialNumber = ''.join([chr(c) for c in mvcc_dev_info.SpecialInfo.stXoFInfo.chSerialNumber if c != 0])
print ("user serial number: %s" % strSerialNumber)
# nConnectionNum = input_func("please input_func the number of the device to connect:")
nConnectionNum = 0
if int(nConnectionNum) >= deviceList.nDeviceNum:
print ("intput error!")
sys.exit()
# ch:创建相机实例 | en:Creat Camera Object
cam = MvCamera()
# ch:选择设备并创建句柄 | en:Select device and create handle
stDeviceList = cast(deviceList.pDeviceInfo[int(nConnectionNum)], POINTER(MV_CC_DEVICE_INFO)).contents
ret = cam.MV_CC_CreateHandle(stDeviceList)
if ret != 0:
print ("create handle fail! ret[0x%x]" % ret)
sys.exit()
# ch:打开设备 | en:Open device
ret = cam.MV_CC_OpenDevice(MV_ACCESS_Exclusive, 0)
if ret != 0:
print ("open device fail! ret[0x%x]" % ret)
sys.exit()
# 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)
# ch:设置触发模式为off | en:Set trigger mode as off
ret = cam.MV_CC_SetEnumValue("TriggerMode", MV_TRIGGER_MODE_OFF)
if ret != 0:
print ("set trigger mode fail! ret[0x%x]" % ret)
sys.exit()
# ch:注册抓图回调 | en:Register image callback
ret = cam.MV_CC_RegisterImageCallBackEx2(CALL_BACK_FUN2, ctypes.py_object(cam), True)
if ret != 0:
print ("register image callback fail! ret[0x%x]" % ret)
sys.exit()
# ch:开始取流 | en:Start grab image
ret = cam.MV_CC_StartGrabbing()
if ret != 0:
print ("start grabbing fail! ret[0x%x]" % ret)
sys.exit()
print ("press Enter key to stop grabbing.")
input_func()
cv2.destroyAllWindows()
# ch:停止取流 | en:Stop grab image
ret = cam.MV_CC_StopGrabbing()
if ret != 0:
print ("stop grabbing fail! ret[0x%x]" % ret)
sys.exit()
# ch:关闭设备 | Close device
ret = cam.MV_CC_CloseDevice()
if ret != 0:
print ("close deivce fail! ret[0x%x]" % ret)
sys.exit()
# ch:销毁句柄 | Destroy handle
ret = cam.MV_CC_DestroyHandle()
if ret != 0:
print ("destroy handle fail! ret[0x%x]" % ret)
sys.exit()
# ch:反初始化SDK | en: finalize SDK
MvCamera.MV_CC_Finalize()