工业相机选型与 Halcon 九点标定法:Python 完整实现指南

摘要:工业相机选型和手眼标定是机器视觉项目的第一步,也是最容易踩坑的环节。本文从实际项目出发,系统讲解工业相机选型的关键参数计算(分辨率、帧率、镜头焦距、景深),并提供基于 Halcon 的九点标定法 Python 完整实现,包含标定板生成、图像采集、仿射变换矩阵计算和精度验证的全流程代码。

关键词:工业相机, 镜头选型, Halcon, 九点标定, 手眼标定, 机器视觉, Python


目录


相机选型:四个必算参数

1. 分辨率计算

公式:分辨率 = 视野宽度 / 最小检测精度

示例:检测一个 200mm × 150mm 的工件,要求最小检测精度 0.1mm。

水平分辨率 = 200 / 0.1 = 2000 像素
垂直分辨率 = 150 / 0.1 = 1500 像素

选择 500 万像素相机(2592 × 1944),满足需求。

2. 帧率计算

公式:帧率 ≥ 1 / 节拍时间

示例:产线节拍 0.5 秒/件,要求拍照 + 处理在 0.3 秒内完成。

最低帧率 = 1 / 0.3 ≈ 3.3 fps

实际选型时留 2 倍余量,选择 ≥ 10 fps 的相机。

3. 传感器尺寸与像元尺寸

传感器尺寸 对角线 (mm) 典型分辨率 像元尺寸 (μm)
1/3" 6.0 1280×960 3.75
1/2" 8.0 1600×1200 4.5
2/3" 11.0 2448×2048 3.45
1" 16.0 4096×3000 3.45

选型原则:像元尺寸越大,感光能力越强,低照度环境下噪点越少。工业检测优先选 3.45μm 以上的像元。

4. 接口类型

接口 带宽 最大线缆长度 适用场景
USB 3.0 5 Gbps 3m 实验室、单机检测
GigE 1 Gbps 100m 产线多相机组网
Camera Link 6.8 Gbps 10m 高速线扫
CoaXPress 25 Gbps 100m 超高分辨率、高速

镜头选型:焦距与工作距离

焦距计算公式

焦距 f = 传感器尺寸 × 工作距离 / 视野宽度

示例:2/3" 传感器(8.8mm 宽),工作距离 300mm,视野 200mm。

f = 8.8 × 300 / 200 = 13.2 mm

选择 12mm 或 16mm 定焦镜头。

景深计算

景深 DOF = 2 × 弥散圆 × F数 × (放大倍率 + 1) / 放大倍率²

其中弥散圆 ≈ 像元尺寸 × 2。

示例:像元 3.45μm,F 数 4,放大倍率 0.044(8.8/200)。

弥散圆 = 3.45 × 2 = 6.9 μm
DOF = 2 × 0.0069 × 4 × (0.044 + 1) / 0.044² ≈ 29.8 mm

景深 30mm 意味着工件在 ±15mm 范围内都能清晰成像。

选型速查表

视野 (mm) 工作距离 (mm) 推荐焦距 (mm) 推荐镜头
50×40 100 16 Computar M1614-MP2
100×80 200 16 Computar M1614-MP2
200×150 300 12 Computar M1214-MP2
300×200 500 16 Computar M1614-MP2
500×400 500 8 Computar M0814-MP2

标定板生成与打印

九点标定需要一个 3×3 的圆点阵列。以下 Python 脚本生成可直接打印的标定板:

"""
generate_calibration_board.py
生成九点标定板 PDF,可直接打印使用
"""

import matplotlib.pyplot as plt
import numpy as np

def generate_calibration_board(
    rows=3, cols=3,
    dot_diameter=5.0,    # 圆点直径 (mm)
    spacing=20.0,         # 圆心间距 (mm)
    board_margin=15.0,    # 边距 (mm)
    output_file="calibration_board.pdf"
):
    """
    生成九点标定板
    """
    # 计算板面尺寸
    board_width = cols * spacing + 2 * board_margin
    board_height = rows * spacing + 2 * board_margin
    
    fig, ax = plt.subplots(figsize=(board_width/25.4, board_height/25.4))
    
    # 绘制圆点
    for row in range(rows):
        for col in range(cols):
            x = board_margin + col * spacing
            y = board_margin + row * spacing
            
            circle = plt.Circle(
                (x, y), dot_diameter/2,
                color='black', fill=True
            )
            ax.add_patch(circle)
            
            # 标注序号
            point_num = row * cols + col + 1
            ax.annotate(
                str(point_num),
                (x, y + dot_diameter/2 + 2),
                fontsize=6, ha='center', va='bottom',
                color='gray'
            )
    
    # 设置坐标轴
    ax.set_xlim(0, board_width)
    ax.set_ylim(0, board_height)
    ax.set_aspect('equal')
    ax.axis('off')
    
    # 添加标尺
    ax.plot([board_margin, board_margin + spacing], 
            [board_height - 5, board_height - 5],
            'k-', linewidth=1)
    ax.text(board_margin + spacing/2, board_height - 7,
            f'{spacing}mm', ha='center', fontsize=8)
    
    plt.tight_layout()
    plt.savefig(output_file, dpi=300, bbox_inches='tight')
    print(f"标定板已保存: {output_file}")
    print(f"板面尺寸: {board_width:.1f}mm × {board_height:.1f}mm")
    print(f"圆心间距: {spacing}mm, 圆点直径: {dot_diameter}mm")

if __name__ == "__main__":
    generate_calibration_board()

打印时选择 100% 比例,不要缩放。用游标卡尺测量实际间距,确认打印精度。


九点标定法原理

九点标定的目标是建立像素坐标系世界坐标系之间的映射关系。

数学模型

使用仿射变换(旋转 + 平移 + 缩放):

X_world = a × x_pixel + b × y_pixel + c
Y_world = d × x_pixel + e × y_pixel + f

其中 (a, b, c, d, e, f) 为待求解的 6 个参数。

求解方法

9 个点提供 18 个方程,6 个未知数,使用最小二乘法求解:

已知:
  像素坐标: (x₁, y₁), (x₂, y₂), ..., (x₉, y₉)
  世界坐标: (X₁, Y₁), (X₂, Y₂), ..., (X₉, Y₉)

构造矩阵:
  A = [[x₁, y₁, 1, 0,  0,  0],
       [0,  0,  0, x₁, y₁, 1],
       [x₂, y₂, 1, 0,  0,  0],
       [0,  0,  0, x₂, y₂, 1],
       ...
       [x₉, y₉, 1, 0,  0,  0],
       [0,  0,  0, x₉, y₉, 1]]

  B = [X₁, Y₁, X₂, Y₂, ..., X₉, Y₉]ᵀ

求解:
  params = (AᵀA)⁻¹AᵀB

Python 完整实现

"""
nine_point_calibration.py
九点标定法完整实现
"""

import numpy as np
import cv2
import json
import os
from typing import List, Tuple, Optional


class NinePointCalibration:
    """九点标定类"""
    
    def __init__(self):
        self.pixel_points = []   # 像素坐标列表
        self.world_points = []   # 世界坐标列表
        self.transform_matrix = None  # 仿射变换矩阵
        self.calibrated = False
    
    def add_point(self, pixel_x: float, pixel_y: float,
                  world_x: float, world_y: float):
        """添加一个标定点"""
        self.pixel_points.append([pixel_x, pixel_y])
        self.world_points.append([world_x, world_y])
        print(f"添加点 {len(self.pixel_points)}: "
              f"像素({pixel_x:.1f}, {pixel_y:.1f}) → "
              f"世界({world_x:.1f}, {world_y:.1f})")
    
    def calibrate(self) -> bool:
        """
        执行标定计算
        返回 True 表示标定成功
        """
        if len(self.pixel_points) < 3:
            print("错误: 至少需要 3 个标定点")
            return False
        
        n = len(self.pixel_points)
        
        # 构造矩阵 A (2n × 6)
        A = np.zeros((2 * n, 6))
        B = np.zeros((2 * n, 1))
        
        for i in range(n):
            px, py = self.pixel_points[i]
            wx, wy = self.world_points[i]
            
            # 填充 A 矩阵
            A[2*i, 0] = px
            A[2*i, 1] = py
            A[2*i, 2] = 1
            A[2*i+1, 3] = px
            A[2*i+1, 4] = py
            A[2*i+1, 5] = 1
            
            # 填充 B 矩阵
            B[2*i] = wx
            B[2*i+1] = wy
        
        # 最小二乘法求解
        try:
            params = np.linalg.inv(A.T @ A) @ A.T @ B
            self.transform_matrix = params.reshape(2, 3)
            self.calibrated = True
            
            print("\n标定完成!")
            print(f"变换矩阵:\n{self.transform_matrix}")
            print(f"X_world = {params[0,0]:.6f} × x + "
                  f"{params[1,0]:.6f} × y + {params[2,0]:.6f}")
            print(f"Y_world = {params[3,0]:.6f} × x + "
                  f"{params[4,0]:.6f} × y + {params[5,0]:.6f}")
            
            return True
        except np.linalg.LinAlgError as e:
            print(f"矩阵求解失败: {e}")
            return False
    
    def pixel_to_world(self, pixel_x: float, pixel_y: float) -> Tuple[float, float]:
        """将像素坐标转换为世界坐标"""
        if not self.calibrated:
            raise RuntimeError("请先执行 calibrate()")
        
        world_x = (self.transform_matrix[0, 0] * pixel_x +
                   self.transform_matrix[0, 1] * pixel_y +
                   self.transform_matrix[0, 2])
        world_y = (self.transform_matrix[1, 0] * pixel_x +
                   self.transform_matrix[1, 1] * pixel_y +
                   self.transform_matrix[1, 2])
        
        return world_x, world_y
    
    def world_to_pixel(self, world_x: float, world_y: float) -> Tuple[float, float]:
        """将世界坐标转换为像素坐标(逆变换)"""
        if not self.calibrated:
            raise RuntimeError("请先执行 calibrate()")
        
        M = np.vstack([self.transform_matrix, [0, 0, 1]])
        M_inv = np.linalg.inv(M)
        
        pixel_x = (M_inv[0, 0] * world_x +
                   M_inv[0, 1] * world_y +
                   M_inv[0, 2])
        pixel_y = (M_inv[1, 0] * world_x +
                   M_inv[1, 1] * world_y +
                   M_inv[1, 2])
        
        return pixel_x, pixel_y
    
    def calculate_error(self) -> dict:
        """计算标定误差"""
        if not self.calibrated:
            raise RuntimeError("请先执行 calibrate()")
        
        errors = []
        for i in range(len(self.pixel_points)):
            px, py = self.pixel_points[i]
            wx_actual, wy_actual = self.world_points[i]
            
            wx_calc, wy_calc = self.pixel_to_world(px, py)
            
            error = np.sqrt((wx_calc - wx_actual)**2 +
                           (wy_calc - wy_actual)**2)
            errors.append(error)
        
        errors = np.array(errors)
        
        return {
            "max_error": np.max(errors),
            "mean_error": np.mean(errors),
            "std_error": np.std(errors),
            "rms_error": np.sqrt(np.mean(errors**2)),
            "per_point": errors.tolist()
        }
    
    def save(self, filepath: str):
        """保存标定结果到 JSON 文件"""
        if not self.calibrated:
            raise RuntimeError("请先执行 calibrate()")
        
        data = {
            "transform_matrix": self.transform_matrix.tolist(),
            "pixel_points": self.pixel_points,
            "world_points": self.world_points,
            "error": self.calculate_error()
        }
        
        with open(filepath, 'w', encoding='utf-8') as f:
            json.dump(data, f, indent=2, ensure_ascii=False)
        
        print(f"标定结果已保存: {filepath}")
    
    @classmethod
    def load(cls, filepath: str) -> 'NinePointCalibration':
        """从 JSON 文件加载标定结果"""
        with open(filepath, 'r', encoding='utf-8') as f:
            data = json.load(f)
        
        calib = cls()
        calib.pixel_points = data["pixel_points"]
        calib.world_points = data["world_points"]
        calib.transform_matrix = np.array(data["transform_matrix"])
        calib.calibrated = True
        
        return calib


def detect_circles_opencv(image_path: str) -> List[Tuple[float, float]]:
    """
    使用 OpenCV 霍夫圆检测提取标定板上的圆点中心
    返回像素坐标列表
    """
    img = cv2.imread(image_path)
    if img is None:
        raise FileNotFoundError(f"无法读取图像: {image_path}")
    
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    
    # 高斯模糊降噪
    blurred = cv2.GaussianBlur(gray, (9, 9), 2)
    
    # 霍夫圆检测
    circles = cv2.HoughCircles(
        blurred,
        cv2.HOUGH_GRADIENT,
        dp=1.2,
        minDist=50,
        param1=100,
        param2=30,
        minRadius=10,
        maxRadius=50
    )
    
    if circles is None:
        print("警告: 未检测到圆点")
        return []
    
    circles = np.uint16(np.around(circles))
    points = [(float(c[0]), float(c[1])) for c in circles[0]]
    
    # 按从上到下、从左到右排序
    points.sort(key=lambda p: (p[1], p[0]))
    
    # 在图像上绘制检测结果
    for i, (x, y) in enumerate(points):
        cv2.circle(img, (int(x), int(y)), 3, (0, 255, 0), -1)
        cv2.putText(img, str(i+1), (int(x)+10, int(y)-10),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1)
    
    output_path = image_path.replace('.', '_detected.')
    cv2.imwrite(output_path, img)
    print(f"检测结果已保存: {output_path}")
    
    return points


def demo_calibration():
    """标定演示"""
    print("=" * 60)
    print("九点标定法演示")
    print("=" * 60)
    
    # 模拟标定数据
    # 像素坐标(从图像中提取)
    pixel_coords = [
        (100, 100), (500, 100), (900, 100),
        (100, 500), (500, 500), (900, 500),
        (100, 900), (500, 900), (900, 900)
    ]
    
    # 世界坐标(标定板上的实际位置,单位 mm)
    world_coords = [
        (0, 0),   (20, 0),   (40, 0),
        (0, 20),  (20, 20),  (40, 20),
        (0, 40),  (20, 40),  (40, 40)
    ]
    
    # 创建标定对象
    calib = NinePointCalibration()
    
    # 添加标定点
    for (px, py), (wx, wy) in zip(pixel_coords, world_coords):
        calib.add_point(px, py, wx, wy)
    
    # 执行标定
    if not calib.calibrate():
        return
    
    # 计算误差
    error = calib.calculate_error()
    print(f"\n标定误差分析:")
    print(f"  最大误差: {error['max_error']:.4f} mm")
    print(f"  平均误差: {error['mean_error']:.4f} mm")
    print(f"  标准差:   {error['std_error']:.4f} mm")
    print(f"  RMS 误差: {error['rms_error']:.4f} mm")
    
    # 逐点误差
    print(f"\n逐点误差:")
    for i, e in enumerate(error['per_point']):
        status = "✓" if e < 0.1 else "✗"
        print(f"  点 {i+1}: {e:.4f} mm {status}")
    
    # 测试转换
    print(f"\n转换测试:")
    test_pixel = (500, 500)
    world = calib.pixel_to_world(*test_pixel)
    print(f"  像素 {test_pixel} → 世界 ({world[0]:.2f}, {world[1]:.2f}) mm")
    
    test_world = (20, 20)
    pixel = calib.world_to_pixel(*test_world)
    print(f"  世界 {test_world} → 像素 ({pixel[0]:.2f}, {pixel[1]:.2f})")
    
    # 保存标定结果
    calib.save("calibration_result.json")


if __name__ == "__main__":
    demo_calibration()

运行输出示例

============================================================
九点标定法演示
============================================================
添加点 1: 像素(100.0, 100.0) → 世界(0.0, 0.0)
添加点 2: 像素(500.0, 100.0) → 世界(20.0, 0.0)
...
添加点 9: 像素(900.0, 900.0) → 世界(40.0, 40.0)

标定完成!
变换矩阵:
[[ 0.05  0.   -5.  ]
 [ 0.    0.05 -5.  ]]
X_world = 0.050000 × x + 0.000000 × y + -5.000000
Y_world = 0.000000 × x + 0.050000 × y + -5.000000

标定误差分析:
  最大误差: 0.0000 mm
  平均误差: 0.0000 mm
  标准差:   0.0000 mm
  RMS 误差: 0.0000 mm

逐点误差:
  点 1: 0.0000 mm ✓
  ...
  点 9: 0.0000 mm ✓

转换测试:
  像素 (500, 500) → 世界 (20.00, 20.00) mm
  世界 (20, 20) → 像素 (500.00, 500.00)

精度验证与误差分析

误差来源

误差来源 典型值 改善措施
镜头畸变 0.5-2% 使用远心镜头或畸变校正
标定板打印精度 0.1-0.3mm 使用光刻标定板
圆点检测精度 0.1-0.5 像素 亚像素边缘检测
安装倾斜 0.1-0.5° 使用水平仪校准
光照不均匀 0.2-0.5 像素 使用同轴光源

精度等级

等级 RMS 误差 适用场景
高精度 < 0.05 mm 半导体、精密装配
中等精度 0.05-0.2 mm 一般工业检测、定位
低精度 0.2-0.5 mm 粗定位、分拣
不合格 > 0.5 mm 需重新标定

常见问题与排查

问题 原因 解决方案
标定误差 > 0.5mm 相机未垂直安装 用水平仪校准相机与标定板平行
边缘点误差大 镜头畸变 使用畸变校正或缩小视野
圆点检测不到 光照不足/过曝 调整光源亮度和角度
检测到多余圆点 背景干扰 增加 ROI 区域限制
标定后定位不准 机械回程差 从同一方向接近目标点

本文由「洵锋」原创,10 年工业自动化全栈工程师。机器视觉项目中最容易被忽视的就是标定精度,建议每次换型后重新标定。评论区欢迎交流实际项目中的视觉问题。

更多推荐