相机内外参

1,274 阅读12分钟

前言

最近我去了一家做无人驾驶的公司,接触到了许多好玩的东东,所以没事就给大家分享一下。

这一篇咱们说一下相机标定参数里的内外参。

 

1-相机内外参的概念

相机内外参是相机标定参数的一部分。

1-1-相机标定

相机标定的作用就确定三维空间中的点位在二维栅格图像中的投影点。

比如,我拿相机拍摄汽车,拍出了一张栅格图像,如下图所示:

image.png

然后我还有一个雷达,我用雷达探测出了汽车的边界点,这些边界点是以我为坐标原点的三维点,如下图所示:

image.png

接下来我想在相机拍出的栅格图像上,画出汽车边界在地面上的边界投影,如下图所示:

image.png

这个时候,我们就需要通过雷达数据算出汽车投影在地面上的三维点,然后在将其投影到图像上。

这个过程就需要用相机标定参数算出三维点在图像上的像素位。

现在我们要说的相机内外参就是属于相机标定参数的一部分。

相机内外参就是相机内参和相机外参。

 

1-2-相机内参

以针孔相机为例,现实世界中的点P1经过相机的光心(相机视点),投影到光心后面的物理成像平面上,变为点P2'。

image.png

点P1和点P2' 是基于光心相互颠倒的,为了方便运算,通常会把点P1投影到相机前面的点P2上。

点P2所在的平面和P2' 所在的平面都是物理成像平面,它们到光心的距离相等,其距离是焦距f。

相机内参的作用就是先将点P1投影到点P2上,然后再将点P2投影到图像上,从而得到其所对应的像素位。

详细解释一下这个投影过程。

相机的坐标系是[O;x,y,z],O是相机的光心,也就是视点,z轴朝前,x轴朝右,y轴朝下。

注意,这个相机坐标系可能和我们以前学过的相机坐标系不一样。

个人猜测这里的相机的坐标系之所以如此定义,是为了与图像的像素坐标系相吻合,因为像素坐标系里的y轴就是朝下的。

接下来咱们求P2点。

由相似三角形性质得:

x1/x2=y1/y2=z1/f

所以:

x2=f*x1/z1
y2=f*y1/z1

由上式可以看出,点P1和点P2 的映射关系,与焦距f 和点p1的z1有关。

点P2所在的坐标系,我们可以暂且将其称之为成像坐标系。

点P1投影到点P2的矩阵模型(行主序)如下:

image.png

因为三维顶点投影到图像上后,就是二维顶点,所以我们并不需要点P2里的z值。

我们可以把点P2里的z值当成齐次坐标来用,默认这个齐次坐标就是1。

image.png 我们暂且将齐次后的点P2称之为点P3(x2,y2,1)。

接下来将点P3 投射到像素平面上。

  image.png

通过上图可知,这是一种线性映射,因此:

x4=kx*x2+bx
y4=ky*x2+by

kx,ky的点P3在x,y 方向上的缩放值;bx,by 是P3在x,y 方向上的位移量。

在实际相机中,kx,ky一般是相等的,它会在相机出厂时被定义好。

bx,by 是像素平面宽、高的一半。

其矩阵模型如下:

image.png

我可以将其记作:

P4=(1/z)*Mi*P1

矩阵Mi 就是相机的内参。

 

1-3-相机外参

在上面说相机内参的时候,我们是没有对相机进行位移和缩放的,以此点P1就可以理解为它在相机坐标系里的位置。

当相机发生了位移和旋转后,就需要把点P1从世界坐标系变换到相机坐标系中,这种变换所需要的矩阵就是相机外参。

相机外参和相机的视图矩阵是类似,但不一定等于视图矩阵,因为这得看你的相机坐标系是否与图像的像素坐标系相匹配,这个我们后面会演示。

设相机的外参矩阵为Me,则点P1到点P4的映射公式为:

P4=(1/z)*Mi*Me*P1

相机外参通常是四维矩阵,相机内参是三维矩阵。

因为相机内参的坐标系z轴朝前,x轴朝右,y轴朝下,相机外参也需要与其一致。

 

2-相机内参示例

小米12S Ultra主摄模组为例,其焦距为23mm,像素尺寸为1.6μm。

image.png

上图中,3.2μm 的四合一像素尺寸除以2后,一个像素的边长就是1.6μm。

μm是微米的意思,1000μm=1mm。

因此,物理成像平面的1mm,对应着像素平面的1000/1.6 个像素的尺寸。

接下来我们用three.js 模拟一个相机,画一个三角形,算出三角形在像素平面上的顶点,然后再用canvas 2d 画出来三角形的轮廓。

image.png

整体代码如下:

import React, { useRef, useEffect } from 'react'
import {
    BufferAttribute,
    BufferGeometry,
    Matrix3,
    Mesh,
    MeshBasicMaterial,
    PerspectiveCamera,
    Scene,
    Vector3,
    WebGLRenderer,
} from 'three'
import './fullScreen.css'


/* 视锥体垂直夹角 */
const degrees = 30
const fov = (degrees * Math.PI) / 180
/* 物理成像平面、像素平面的宽高比 */
const aspect = 1
/* 焦距 */
const f = 23
/* 像素平面与物理成像平面的比值 */
const scale = 1000 / 16
/* 物理成像平面尺寸 */
const fh = f * Math.tan(fov / 2)*2
const fw = fh * aspect
/* 像素平面尺寸 */
const imgH = fh * scale
const imgW = fw * scale


/* 三维内参矩阵,默认相机x朝右,y轴朝下,z轴朝前*/
const intrinsics = new Matrix3().set(
  scale*f, 0, imgW/2,
  0, scale*f, imgH/2, 
  0, 0, 1
)


/* 应用内参矩阵 */
function cameraToImg({x,y,z}:Vector3,{elements:e}:Matrix3){
  return new Vector3(
    (e[ 0 ] * x + e[ 3 ] * y + e[ 6 ] * z)/z,
    (e[ 1 ] * x + e[ 4 ] * y + e[ 7 ] * z)/z,
    (e[ 2 ] * x + e[ 5 ] * y + e[ 8 ] * z)/z
  );
}


/* 相机坐标系里的图形顶点 */
const trianglePoints=[
  new Vector3(0,  -100,  -1000),
  new Vector3(100, 100,  -1000),
  new Vector3(-100, 100,  -1000),
]




/* 场景 */
const scene = new Scene()


/* 渲染器 */
const renderer:WebGLRenderer =new WebGLRenderer({ antialias: true })
renderer.setSize(imgW,imgH,false)


/* 相机 */
const camera = new PerspectiveCamera(degrees, aspect, 0.1, 5000)


/* 三角形 */
const mat=new MeshBasicMaterial({
  color:0x00acec
})
const geo=new BufferGeometry()
const vertices = new Float32Array( [
    ...trianglePoints.map(({x,y,z}:Vector3)=>[x,y,z]).flat()
] );
geo.setAttribute( 'position', new BufferAttribute( vertices, 3 ) );
const mesh=new Mesh(geo,mat)
scene.add(mesh)




/* canvas2D 绘图 */
const canvas2D=document.createElement('canvas')
canvas2D.width=imgW
canvas2D.height=imgH
const ctx=canvas2D.getContext('2d')


drawShapeInCanvas2D()


function drawShapeInCanvas2D(){
  if(!ctx){return}


  /* 相机坐标系里的顶点在像素平面上的投影 */
  const points=trianglePoints.map((p:Vector3)=>{
    return cameraToImg(p,intrinsics)
  })
  
  ctx.clearRect(0,0,canvas2D.width,canvas2D.height)
  ctx.save()
  ctx.strokeStyle='#fff'
  ctx.lineWidth=3
  ctx.setLineDash([32,6,2,6])
  ctx.beginPath()
  ctx.moveTo(points[0].x,points[0].y)
  for(let i=1,len=points.length;i<len;i++){
    ctx.lineTo(points[i].x,points[i].y)
  }
  ctx.closePath()
  ctx.stroke()
  ctx.restore()
}


const Extrinsics: React.FC = (): JSX.Element => {
  const divRef = useRef<HTMLDivElement>(null)
  
    useEffect(() => {
    const { current } = divRef
        if (!current) {
            return
        }
        current.append(renderer.domElement,canvas2D)
        renderer.render(scene, camera)
        return () => {
      renderer.domElement.remove()
      canvas2D.remove()
    }
    }, [])
    return <div ref={divRef} className="canvasWrapper"></div>
}


export default Extrinsics

当前案例我是用react+three.js+ts 写的。

在其生成的html 里面,有两个canvas:一个是three.js的,一个是canvas 2d 的。

<div class="canvasWrapper">
    <canvas data-engine="three.js r140" width="770" height="770" style="display: block;"></canvas>
    <canvas width="770" height="770"></canvas>
</div>

three.js 绘制的是蓝色三角形,canvas 2d 画的是其描边。

接下来咱们具体解释一下其代码。

1.根据焦距和像素尺寸建立相应的相机数据。

/* 视锥体垂直夹角 */
const degrees = 30
const fov = (degrees * Math.PI) / 180
/* 物理成像平面、像素平面的宽高比 */
const aspect = 1
/* 焦距 */
const f = 23
/* 像素平面与物理成像平面的比值 */
const scale = 1000 / 16
/* 物理成像平面尺寸 */
const fh = f * Math.tan(fov / 2)*2
const fw = fh * aspect
/* 像素平面尺寸 */
const imgH = fh * scale
const imgW = fw * scale

degrees, aspect可以做为实例化PerspectiveCamera的数据。

之后我们会用PerspectiveCamera 模拟现实中的相机。

imgH,imgW 是canvas画布的尺寸。

2.建立相机的内参矩阵。

/* 三维内参矩阵,默认相机x朝右,y轴朝下,z轴朝前*/
const intrinsics = new Matrix3().set(
  scale*f, 0, imgW/2,
  0, scale*f, imgH/2, 
  0, 0, 1
)

3.建立内参矩阵对相机坐标系里的点位的变换方法。

function cameraToImg({x,y,z}:Vector3,{elements:e}:Matrix3){
  return new Vector3(
    (e[ 0 ] * x + e[ 3 ] * y + e[ 6 ] * z)/z,
    (e[ 1 ] * x + e[ 4 ] * y + e[ 7 ] * z)/z,
    (e[ 2 ] * x + e[ 5 ] * y + e[ 8 ] * z)/z
  );
}

4.创建三个相机坐标系里的图形顶点用于测试。

const trianglePoints=[
  new Vector3(0,  -100,  -1000),
  new Vector3(100, 100,  -1000),
  new Vector3(-100, 100,  -1000),
]

 

5.创建场景。

/* 场景 */
const scene = new Scene()


/* 渲染器 */
const renderer:WebGLRenderer =new WebGLRenderer({ antialias: true })
renderer.setSize(imgW,imgH,false)


/* 相机 */
const camera = new PerspectiveCamera(degrees, aspect, 0.1, 5000)


/* 三角形 */
const mat=new MeshBasicMaterial({
  color:0x00acec
})
const geo=new BufferGeometry()
const vertices = new Float32Array( [
    ...trianglePoints.map(({x,y,z}:Vector3)=>[x,y,z]).flat()
] );
geo.setAttribute( 'position', new BufferAttribute( vertices, 3 ) );
const mesh=new Mesh(geo,mat)
scene.add(mesh)

相机不要动,要保持最初始状态,因为我们当前模拟的是相机坐标系里的顶点在像素平面上的投影。

 

6.创建一个canvas 2d画布,用于测试投影结果。

/* canvas2D 绘图 */
const canvas2D=document.createElement('canvas')
canvas2D.width=imgW
canvas2D.height=imgH
const ctx=canvas2D.getContext('2d')


drawShapeInCanvas2D()


function drawShapeInCanvas2D(){
  if(!ctx){return}
  const points=trianglePoints.map((p:Vector3)=>{
    return cameraToImg(p,intrinsics)
  })
  ctx.clearRect(0,0,canvas2D.width,canvas2D.height)
  ctx.save()
  ctx.strokeStyle='#fff'
  ctx.lineWidth=3
  ctx.setLineDash([32,6,2,6])
  ctx.beginPath()
  ctx.moveTo(points[0].x,points[0].y)
  for(let i=1,len=points.length;i<len;i++){
    ctx.lineTo(points[i].x,points[i].y)
  }
  ctx.closePath()
  ctx.stroke()
  ctx.restore()
}

在drawShapeInCanvas2D() 方法中,便是将三角形在相机坐标系内的顶点转换成图像里的像素点,绘制的路径。

 

3-相机外参示例

在接下来的示例里,我们会对相机的进行位移和旋转。

这时候,3d图形的顶点就需要从世界坐标系转入相机坐标系,这种转换就要用到相机外参。

整体代码如下:

import React, { useRef, useEffect } from 'react'
import {
    BufferAttribute,
    BufferGeometry,
    Matrix3,
    Matrix4,
    Mesh,
    MeshBasicMaterial,
    PerspectiveCamera,
    Scene,
    Vector3,
    WebGLRenderer,
} from 'three'
import './fullScreen.css'


/* 视锥体垂直夹角 */
const degrees = 30
const fov = (degrees * Math.PI) / 180
/* 物理成像平面、像素平面的宽高比 */
const aspect = 1
/* 焦距 */
const f = 23
/* 像素平面与物理成像平面的比值 */
const scale = 1000 / 16
/* 物理成像平面尺寸 */
const fh = f * Math.tan(fov / 2)*2
const fw = fh * aspect
/* 像素平面尺寸 */
const imgH = fh * scale
const imgW = fw * scale


/* 三维内参矩阵,默认相机x朝右,y轴朝下,z轴朝前*/
const intrinsics = new Matrix3().set(
  scale*f, 0, imgW/2,
  0, scale*f, imgH/2, 
  0, 0, 1
)


/* 应用内参矩阵 */
function cameraToImg({x,y,z}:Vector3,{elements:e}:Matrix3){
  return new Vector3(
    (e[ 0 ] * x + e[ 3 ] * y + e[ 6 ] * z)/z,
    (e[ 1 ] * x + e[ 4 ] * y + e[ 7 ] * z)/z,
    (e[ 2 ] * x + e[ 5 ] * y + e[ 8 ] * z)/z
  );
}


/* 相机坐标系里的图形顶点 */
const trianglePoints=[  new Vector3(0,  -100,  -1000),  new Vector3(100, 100,  -1000),  new Vector3(-100, 100,  -1000),]




/* 场景 */
const scene = new Scene()


/* 渲染器 */
const renderer:WebGLRenderer =new WebGLRenderer({ antialias: true })
renderer.setSize(imgW,imgH,false)


/* 相机 */
const camera = new PerspectiveCamera(degrees, aspect, 0.1, 5000)
camera.position.set(1000,600,0)
camera.lookAt(200,100,-1000)
camera.updateMatrix()


/* 三角形 */
const mat=new MeshBasicMaterial({
  color:0x00acec
})
const geo=new BufferGeometry()
const vertices = new Float32Array( [
    ...trianglePoints.map(({x,y,z}:Vector3)=>[x,y,z]).flat()
] );
geo.setAttribute( 'position', new BufferAttribute( vertices, 3 ) );
const mesh=new Mesh(geo,mat)
scene.add(mesh)


/* canvas2D 绘图 */
const canvas2D=document.createElement('canvas')
canvas2D.width=imgW
canvas2D.height=imgH
const ctx=canvas2D.getContext('2d')


drawShapeInCanvas2D()


function drawShapeInCanvas2D(){
  if(!ctx){return}
  /* 相机外参
  three.js中,相机y轴朝上,x轴朝右;
  相机invert()后,y轴朝下,x轴朝左;
  因需要与intrinsics 里的坐标系保持一致,x轴需要向右,所以需要将相机视图矩阵的逆矩阵基于x轴做一次反转。
  */
  const extrinsics=new Matrix4().set(
    -1,0,0,0,
    0,1,0,0,
    0,0,1,0,
    0,0,0,1,
  ).multiply(camera.matrix.clone().invert()) 


  /* 世界坐标系里的顶点在像素平面上的投影 */
  const points=trianglePoints.map((p:Vector3)=>{
    const p2=p.clone().applyMatrix4(extrinsics)
    return cameraToImg(p2,intrinsics)
  })
  ctx.clearRect(0,0,canvas2D.width,canvas2D.height)
  ctx.save()
  ctx.strokeStyle='#fff'
  ctx.lineWidth=3
  ctx.setLineDash([32,6,2,6])
  ctx.beginPath()
  ctx.moveTo(points[0].x,points[0].y)
  for(let i=1,len=points.length;i<len;i++){
    ctx.lineTo(points[i].x,points[i].y)
  }
  ctx.closePath()
  ctx.stroke()
  ctx.restore()
}


const Extrinsics: React.FC = (): JSX.Element => {
  const divRef = useRef<HTMLDivElement>(null)
  
    useEffect(() => {
    const { current } = divRef
        if (!current) {
            return
        }
        current.append(renderer.domElement,canvas2D)
        renderer.render(scene, camera)
        return () => {
      renderer.domElement.remove()
      canvas2D.remove()
    }
    }, [])
    return <div ref={divRef} className="canvasWrapper"></div>
}


export default Extrinsics

效果如下:

image.png

解释一下上面的代码。

1.在之前代码的基础上,调整相机的位移和旋转。

const camera = new PerspectiveCamera(degrees, aspect, 0.1, 5000)
camera.position.set(1000,600,0)
camera.lookAt(200,100,-1000)
camera.updateMatrix()

 

2.在drawShapeInCanvas2D() 方法中,计算外参,然后再结合内参把世界坐标系里的3d 图形投影到2d图像上。

function drawShapeInCanvas2D(){
  if(!ctx){return}
  /* 相机外参
  three.js中,相机y轴朝上,x轴朝右;
  相机invert()后,y轴朝下,x轴朝左;
  因需要与intrinsics 里的坐标系保持一致,x轴需要向右,所以需要将相机视图矩阵的逆矩阵基于x轴做一次反转。
  */
  const extrinsics=new Matrix4().set(
    -1,0,0,0,
    0,1,0,0,
    0,0,1,0,
    0,0,0,1,
  ).multiply(camera.matrix.clone().invert()) 


  /* 世界坐标系里的顶点在像素平面上的投影 */
  const points=trianglePoints.map((p:Vector3)=>{
    const p2=p.clone().applyMatrix4(extrinsics)
    return cameraToImg(p2,intrinsics)
  })
  ctx.clearRect(0,0,canvas2D.width,canvas2D.height)
  ctx.save()
  ctx.strokeStyle='#fff'
  ctx.lineWidth=3
  ctx.setLineDash([32,6,2,6])
  ctx.beginPath()
  ctx.moveTo(points[0].x,points[0].y)
  for(let i=1,len=points.length;i<len;i++){
    ctx.lineTo(points[i].x,points[i].y)
  }
  ctx.closePath()
  ctx.stroke()
  ctx.restore()
}

extrinsics 就是外参矩阵,它先对相机的模型矩阵进行invert,从而得到相机的视图矩阵,然后再对其x轴取反,使之与图像的像素坐标系相匹配。

 

总结

当前我只是说了相机标定参数里的内外参。

相机标定参数里还有一个重要的参数是畸变,这个我得另起一篇再说了。

我们可以在OpenCV 里找到相机标定的相关的方法。

因为我走的是前端图形学方向,对OpenCV没有太多了解,所以就没用它举例子,但以后计划学一下。

对相机标定的理解,会对我们与计算机视觉的合作有所帮助。

参考链接:

zhuanlan.zhihu.com/p/389653208

zhuanlan.zhihu.com/p/587858107