基于多摄像头二维信息的三维定位

2026-08-31 21:35:30 2

二维图像只能告诉我们目标位于哪条视线上,真正的深度信息来自多视角之间的几何关系。

这次实践使用一台云台相机,在三个固定预置位分别拍摄目标,通过相机标定、像素反投影和多视角射线交会,已经成功跑通了从二维观测到三维世界坐标输出的完整链路。

这里的“跑通”指算法流程和数据链路已经闭环;当前标定误差还需要继续优化,才能进入正式精度验收。


一、先把世界坐标系定义清楚


现场空间实际尺寸为:

长度:1980 mm,定义为世界 X 方向
宽度:1200 mm,定义为世界 Y 方向
高度:800 mm,定义为世界 Z 方向

三个拍摄位置的相机光心分别为:

位置 1:[0, 50, 800] mm
位置 2:[0, 1150, 800] mm
位置 3:[1980, 50, 800] mm

标定棋盘左上第一个内角点在世界坐标中的位置分别为:(每个位置对应的标定板的位置不一样)


位置 1:[853, 50, 0] mm
位置 2:[780, 770, 0] mm
位置 3:[825, 60, 0] mm

目标纸巾的人工测量中心为:

[930, 524, 30] mm

所有计算内部统一使用米,最终同时输出米和毫米,避免在矩阵运算中混用单位。

下面三张图片的纸巾位置都是一样的, 只是云台拍摄角度不一样




注意

1. 10个格子的是Y轴, 7个格子的是X轴, 标定板放在地面上, 平行于Z轴. 相机更换了位置, 标定板的位置可以变动, 方向不用变动

2. 最靠近原点的是第一个内角点


二、二维检测点不等于物体三维中心

三角测量恢复的是“同一个物理点”,而不是整个检测框。

如果检测框格式为:

[xmin, ymin, xmax, ymax]

框中心可以写成:

u = (xmin + xmax) / 2.0
v = (ymin + ymax) / 2.0

对于放在地面上的目标,更合理的参考点通常是底部中心:

u = (xmin + xmax) / 2.0
v = ymax

这次测量使用的是检测框中心,因此结果应理解为“目标视觉中心”的近似值。不同视角下的框中心不一定对应物体上的同一个物理点,这也是普通目标定位精度受限的原因之一。

三、先标定相机自身的成像模型

相机输出图像分辨率为:

2304 x 1296

使用 9 x 6 个棋盘内角点, 实际是10 x 7个格子,方格实际边长为:

24 mm

通过 33 张标定图像计算得到内参矩阵:


K = np.array([
    [1320.1717,    0.0,    1187.5307],
    [   0.0,    1319.2484,  708.4240],
    [   0.0,       0.0,       1.0]
])

畸变参数为:

dist_coeffs = np.array([
    -0.1889467,
    -0.1578081,
    -0.0055605,
    -0.0019354,
     0.1654711
])

标定整体重投影 RMS 为:

0.7653 px

内参标定的核心过程是:

gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)

ok, corners = cv2.findChessboardCorners(
    gray,
    (9, 6)
)

corners = cv2.cornerSubPix(
    gray,
    corners,
    (11, 11),
    (-1, -1),
    criteria
)

rms, K, dist_coeffs, rvecs, tvecs = cv2.calibrateCamera(
    object_points,
    image_points,
    image_size,
    None,
    None
)

这一阶段只回答一个问题:

相机如何把三维世界投影成二维图像?

它还不能告诉我们相机在房间中的位置。

四、通过棋盘得到相机外参

外参描述相机相对于世界坐标系的位置和方向。

棋盘角点在相机中的位姿可以通过 PnP 求解:

ok, rvec, tvec = cv2.solvePnP(
    object_points_board,
    image_points_camera,
    K,
    dist_coeffs,
    flags=cv2.SOLVEPNP_ITERATIVE
)

solvePnP 得到的是:

P_camera = T_camera_board * P_board

而三维定位需要的是相机坐标到世界坐标的变换:

P_world = T_world_camera * P_camera

因此需要先构造:

R_camera_board, _ = cv2.Rodrigues(rvec)

T_camera_board = np.eye(4)
T_camera_board[:3, :3] = R_camera_board
T_camera_board[:3, 3] = tvec.reshape(3)

T_world_camera = (
    T_world_board @ np.linalg.inv(T_camera_board)
)

这里最容易出错的是矩阵方向。T_camera_board 和 T_world_camera 不能混用,否则最终得到的坐标可能数值看似合理,但物理位置完全错误。

五、二维像素如何变成空间射线

对于目标像素 (u, v),第一步是去除镜头畸变:

point = np.array([[[u, v]]], dtype=np.float64)

normalized = cv2.undistortPoints(
    point,
    K,
    dist_coeffs
)[0, 0]

得到归一化相机坐标后,构造相机坐标系中的方向:

ray_camera = np.array([
    normalized[0],
    normalized[1],
    1.0
])

ray_camera /= np.linalg.norm(ray_camera)

再通过外参旋转到世界坐标系:

R_world_camera = T_world_camera[:3, :3]
camera_center_world = T_world_camera[:3, 3]

ray_world = R_world_camera @ ray_camera
ray_world /= np.linalg.norm(ray_world)

这样,一个二维点就被转换成了世界坐标系中的射线:

L(lambda) = C + lambda * d

其中:

● C 是相机光心;

● d 是世界坐标中的射线方向;

● lambda > 0 表示目标位于相机前方。

单张普通 RGB 图像只能得到一条射线,所以无法独立恢复任意目标的三维坐标

六、两条或多条射线交会得到三维点

对于每条射线,使用正交投影矩阵:

projector = (
    np.eye(3) -
    np.outer(direction, direction)
)

A += projector
b += projector @ origin

最后通过最小二乘求解:

point_world, _, _, _ = np.linalg.lstsq(
    A,
    b,
    rcond=None
)

这实际上是在求一个三维点,使它到所有观测射线的正交距离之和最小:

P* = argmin Σ ||(I - dᵢdᵢᵀ)(P - Cᵢ)||²

理想情况下,多条射线应该交于同一点。现实中由于像素误差、标定误差和目标框语义误差,它们通常不会完全相交,因此需要输出射线间距作为质量指标。

七、定位结果

以人工测量中心 [930, 524, 30] mm 作为参考,三视角计算得到:

三视角结果:
[931.75, 548.68, 52.52] mm

与人工测量值的空间距离约为:

33.5 mm

使用两个视角进行复核,得到:

双视角结果:
[940.67, 565.81, 39.86] mm

与人工测量值的空间距离约为:

44.3 mm

从结果上看,X 方向已经比较接近,主要误差集中在 Y 和 Z 方向。这说明从二维像素、相机标定到三维交会的计算链路已经能够工作,当前精度瓶颈主要来自外参质量和二维参考点定义。


基于多摄像头二维信息的三维定位

基于多摄像头二维信息的三维定位

二维图像只能告诉我们目标位于哪条视线上,真正的深度信息来自多视角之间的几何关系。这次实践使用一台云台相机,在三个固定预置位分别拍摄目标,通过相机标定、像素反投影和多视角射线交会,已经成功跑通了从二维观测到三维世界坐标输出的完整链路。这里的“跑通”指算法流程和数据链路已经闭环;当前标定误差还需要继续优
2026-08-31

freemarker 时间显示不正常 设置时区

项目在本地开发的时候显示正常,部署上服务器就一直差8个小时,最后发现freemarker官方文档有这样的说明time_zone:时区的名称来显示并格式化时间。 默认情况下,使用JVM的时区。 也可以是 Java 时区 API 接受的值,或者 "JVM default" (从 FreeMarker 2
2020-03-28
IDEA 2019.1 xml 不高亮

IDEA 2019.1 xml 不高亮

前几天更新了idea后,发现xml里的代码都没有了高亮,变得跟记事本一个德性了打开setting ,搜索 File Types,找到xml项, 查看下方的匹配格式,果然没有xml,(idea真是厉害)点击右方的+,输入*.xml,点击ok,解决问题
2020-03-28

npm install 淘宝镜像

npm install --registry=https://registry.npm.taobao.org
2020-03-28
Java中方法的参数传递机制

Java中方法的参数传递机制

来看一段代码 public class Man { private String name; private Integer age; public String getName() { return name; } publi
2020-03-28
基于自定义注解手写权限控制

基于自定义注解手写权限控制

方法一: AOP 方法二: 拦截器项目结构项目依赖<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-w
2020-03-28

Docker 部署 详细全过程 附代码

Docker 部署本站 全过程环境:CentOS7.61. 安装Docker其他版本CentOS可以参考这个https://help.aliyun.com/document_detail/187598.html查看本机内核版本,内核版本需高于 3.10uname -r 确保 yum 包最新yum u
2020-03-28

SpringBoot 启动普通java工程

引入依赖<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> <version>2.0.9</version> </dependency>
2020-03-28

Vue.js DOM操作

<template> <input type="button" @click="reply($event)" value="回复"> </template> export default { methods: { replyFun(e) {
2020-03-29
CentOS7编译调试OpenJDK12

CentOS7编译调试OpenJDK12

1. 下载源码https://hg.openjdk.java.net/jdk/jdk12点击左侧的browse,再点击zip,就可以下载zip格式的源码压缩包。unzip xxx.zip 解压文件2. 安装jdkyum install java-11-openjdk-devel -y3. 运行con
2020-04-23
编写自己的Spring Boot Starter

编写自己的Spring Boot Starter

1.新建一个maven项目命名规则统一是xxx-spring-boot-starter完整pom.xml<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0"
2020-06-29