Three.js真实相机模拟

有没有想过如何在 3D Web 应用程序中模拟物理相机? 在这篇博文中,我将向你展示如何使用 Three.js和 OpenCV 来完成此操作。 我们将从模拟针孔相机模型开始,然后添加真实的镜头畸变。 具体来说,我们将仔细研究 OpenCV 的两个失真模型,并使用后处理着色器复制它们。

拥有逼真的模拟相机可以让你在真实相机捕获的图像上渲染 3D 场景。 例如,这可以用于增强现实,也可以用于机器人和自动驾驶车辆。 这是因为机器人和自动驾驶汽车通常结合了 3D 传感器(如激光雷达)和摄像头,在摄像头图像上可视化 3D 数据对于验证传感器校准非常重要。 在创建和检查 3D 标注时它也非常有帮助,这就是我在 Segments.ai 上解决这个问题的原因。

为了测试我们的相机模拟,我们将使用 nuScenes 数据集中的帧,将激光雷达捕获的 3D 点云放置在相机图像的顶部。 无论你是从事机器人/AV 工作、开发可视化工具、开发 AR 应用程序,还是只是对计算机视觉和 3D 图形感兴趣,本指南都希望能教会你一些新知识。 那么让我们开始吧!

1、针孔相机模型

为了以 3D 方式复制相机,我们首先需要一种以数学方式表示相机的方法,即相机模型。 从根本上来说,相机将 3D 世界点映射到 2D 图像平面。 因此,我们寻找一个输入3D 点 [x y z] 输出2D点 [u v]的函数(通常以像素坐标定义)。

针孔相机

最简单的相机模型是针孔相机模型。 针孔相机没有镜头; 光只是通过一个点(“针孔”)进入并在图像平面上形成图像。 这种类型的相机(也称为暗箱)已经被制造了数千年(很可能你小时候就自己制作过一台)。

如果我们使用齐次坐标,则针孔模型可以在数学上表示为简单的线性变换。 该变换可以写成 3 x 4 矩阵,称为相机矩阵M。通常,我们将这个矩阵分成两个矩阵:一个 3 x 3 内部相机矩阵和一个 3 x 4 外部矩阵。 相机姿态,即它在世界中的位置和旋转被编码在外部矩阵中。 内在矩阵包含相机的焦距、像素大小和图像原点。

其中:

  • fx和fy是以像素为单位的焦距,对于正方形像素来说,fx=fy
  • s表示x轴和y轴之间的倾斜系数,通常为0
  • Ox和Oy是基准点距图像帧左上角的(绝对)偏移量(以像素为单位)
  • [R T]是从世界坐标到相机坐标的变换,R 是旋转矩阵,T是平移向量
  • 因为我们在齐次坐标中工作,所以我们向K添加一列额外的0,向[R T]矩阵
    添加一排以 1 结尾的 0

内部和外部参数可以通过称为相机校准的过程来估计。 这通常涉及从不同视点捕获已知校准图案(例如棋盘)的图像。 OpenCV 包含估计相机内在和外在参数以及畸变系数的函数(稍后会详细介绍)。 查看此 OpenCV 教程,了解如何使用棋盘图案校准相机。

对于上述示例图像,校准参数为:

{
  "K": [
    809.2209905677063, 0, 829.2196003259838, 
    0, 809.2209905677063, 481.77842384512485, 
    0, 0, 1
  ],
  "R": [
    -0.99994107, -0.00469355, 0.00978885, 
    -0.00982374, 0.0074685, -0.99992385,
    0.00462008, -0.9999611, -0.00751417
  ],
  "T": [-0.00526441, -0.27648432, -0.91085728],
  "imageWidth": 1600,
  "imageHeight": 900
}

2、Three.js 中的针孔相机模型

校准相机后,我们现在可以在浏览器中模拟相机。 浏览器有两个主要的 API 可用于高效渲染 3D 内容:WebGL 和较新的 WebGPU。 然而,这些 API 非常底层,因此我们将使用流行的 Three.js 库,而不是直接使用它们。

我们首先创建一个网页,其中包含图像和覆盖在其上的 3D 应用程序:

<html>
  <head>
    <title>PinholeCamera</title>
    <style>
      body {
        margin: 0;
        overflow: hidden;
      }
      canvas {
        width: 100%;
        height: 100%;
        position: absolute;
        top: 0;
        left: 0;
      }
      img {
        width: 100%;
        height: 100%;
        object-fit: contain;
      }
    </style>
  </head>
  <body>
    <img src="https://segmentsai-prod.s3.eu-west-2.amazonaws.com/assets/admin-tobias/353346e3-1d10-4343-94d2-95c826755ab9.jpg">
    <div id="app"></div>

    <script src="src/index.ts"></script>
  </body>
</html>

接下来,我们将创建 index.ts 文件,在其中使用我们将要制作的相机和渲染器设置基本的 Three.js 场景。 通过将渲染器的alpha值设置为true,我们就可以看到3D场景下的图像。

我们将使用 Three.js 中的 PCDLoader 来加载点云。 加载后,我们将为其指定颜色并将其添加到场景中。

import {
  WebGLRenderer,
  Scene,
  Matrix3,
  Vector3,
  PointsMaterial,
  Color,
} from "three";
import calibration from "./calibration.json";
import PinholeCamera from "./PinholeCamera";
import { PCDLoader } from "three/examples/jsm/loaders/PCDLoader";

const { K, R, T, imageWidth, imageHeight } = calibration;
// fromArray reads in column-major order
const matrixK = new Matrix3().fromArray(K).transpose();
const matrixR = new Matrix3().fromArray(R).transpose();
const vectorT = new Vector3().fromArray(T);

const scene = new Scene();
const camera = new PinholeCamera(
  matrixK,
  matrixR,
  vectorT,
  imageWidth,
  imageHeight,
  window.innerWidth / window.innerHeight,
  0.1,
  1000
);

const loader = new PCDLoader();
loader.load(
  "https://segmentsai-prod.s3.eu-west-2.amazonaws.com/assets/admin-tobias/41089c53-efca-4634-a92a-0c4143092374.pcd",
  function (points) {
    (points.material as PointsMaterial).size = 2;
    (points.material as PointsMaterial).color = new Color(0x00ffff);
    scene.add(points);
  },
  function (xhr) {
    console.log((xhr.loaded / xhr.total) * 100 + "% loaded");
  },
  function (e) {
    console.error("Error when loading the point cloud", e);
  }
);

const renderer = new WebGLRenderer({
  alpha: true,
});
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

function animate() {
  requestAnimationFrame(animate);
  renderer.render(scene, camera);
}

animate();

要创建我们的针孔相机,我们首先创建一个新类,该类扩展了 Three.js 中的 PerspectiveCamera 类:

export default class PinholeCamera extends PerspectiveCamera {
  K: Matrix3;
  imageWidth: number;
  imageHeight: number;

  constructor(
    K: Matrix3,
    R: Matrix3,
    T: Vector3,
    imageWidth: number,
    imageHeight: number,
    aspect: number,
    near: number,
    far: number
  ) {
    super(45, aspect, near, far);

    this.setExtrinsicMatrix(R, T);

    this.K = K;
    this.imageWidth = imageWidth;
    this.imageHeight = imageHeight;
    this.updateProjectionMatrix();
  }

  setExtrinsicMatrix(R: Matrix3, T: Vector3) {
    // TODO
  }

  updateProjectionMatrix() {
    // TODO
  }
}

当我们调用 PerspectiveCamera 的构造函数时,我们必须传入一个视野 (FOV) 值。 Three.js 在 updateProjectionMatrix 方法中使用该值,但我们将重写该方法并使用内部矩阵中的焦距,因此不会使用初始 FOV。

3、设置相机外部参数

我们可以根据相机外参数设置相机位姿(位置+方向),如下所示:

setExtrinsicMatrix(R, T) {
  const rotationMatrix4 = new Matrix4().setFromMatrix3(R);
  rotationMatrix4.setPosition(T);
  rotationMatrix4.invert();
  this.quaternion.setFromRotationMatrix(rotationMatrix4);
  this.position.setFromMatrixPosition(rotationMatrix4);
}

请注意,在使用外部矩阵设置相机位置和航向之前,我们必须先反转外部矩阵。 这是因为 [R T]表示从世界到相机的变换,我们需要从相机到世界的变换(相当于相机在世界坐标中的位置/航向)。

4、设置相机内部参数

设置相机内部参数有点复杂。 Three.js 不使用与我们在相机校准过程中获得的相同的内在矩阵。 相反,它使用与 WebGL 相同的矩阵,我们的内在矩阵大致对应于 WebGL 中的“投影矩阵”。 对我们来说幸运的是,Kyle Simek 写了一篇博文解释如何将内参矩阵转换为有效的投影矩阵。

我们将使用博客中描述的 glOrtho 方法来获取透视矩阵。 然而,我们无法直接访问OpenGL函数,因此我们必须在makeNdcMatrix函数中重新实现glOrtho。 对于 makePerspectiveMatrix 方法,我们还将进行一个小更改:我们不必对内参矩阵的第三列取反,因为相机在 OpenCV 中向下看正 z 轴。

function makeNdcMatrix(
  left: number,
  right: number,
  bottom: number,
  top: number,
  near: number,
  far: number
) {
  const tx = -(right + left) / (right - left);
  const ty = -(top + bottom) / (top - bottom);
  const tz = -(far + near) / (far - near);

  const ndc = new Matrix4();
  // prettier-ignore
  ndc.set(
      2 / (right - left), 0, 0, tx,
      0, 2 / (top - bottom), 0, ty,
      0, 0, -2 / (far - near), tz,
      0, 0, 0, 1,
    );
  return ndc;
}

function makePerspectiveMatrix(
  s: number,
  alpha: number,
  beta: number,
  x0: number,
  y0: number,
  near: number,
  far: number
) {
  const A = near + far;
  const B = near * far;

  const perspective = new Matrix4();
  // prettier-ignore
  perspective.set(
      alpha, s, x0, 0,
      0, beta, y0, 0,
      0, 0, -A, B,
      0, 0, 1, 0,
    );
  return perspective;
}

现在我们可以重写 PerspectiveCamera 类的 updateProjectionMatrix 方法。

updateProjectionMatrix() {
  if (!this.K) {
    return;
  }
  // column-major order
  const fx = this.K.elements[0 + 0 * 3];
  const fy = this.K.elements[1 + 1 * 3];
  const ox = this.K.elements[0 + 2 * 3];
  const oy = this.K.elements[1 + 2 * 3];
  const s = this.K.elements[0 + 1 * 3];

  const imageAspect = this.imageWidth / this.imageHeight;
  const relAspect = this.aspect / imageAspect;

  const relAspectFactorX = Math.max(1, relAspect);
  const relAspectFactorY = Math.max(1, 1 / relAspect);

  const relAspectOffsetX = ((1 - relAspectFactorX) / 2) * this.imageWidth;
  const relAspectOffsetY = ((1 - relAspectFactorY) / 2) * this.imageHeight;

  const left = relAspectOffsetX;
  const right = this.imageWidth - relAspectOffsetX;
  const top = relAspectOffsetY;
  const bottom = this.imageHeight - relAspectOffsetY;

  const persp = makePerspectiveMatrix(s, fx, fy, ox, oy, this.near, this.far);
  const ndc = makeNdcMatrix(left, right, bottom, top, this.near, this.far);
  const projection = ndc.multiply(persp);

  this.projectionMatrix.copy(projection);
  this.projectionMatrixInverse.copy(this.projectionMatrix).invert();
}

relAspect 对于考虑原始相机图像和浏览器窗口之间的宽高比差异是必要的。

将它们放在一起,我们可以看到点云叠加在相机图像上。

5、模拟镜头畸变

具有鱼眼镜头畸变的图像

大多数镜头相机镜头都会导致图像扭曲(特殊镜头除外)。 使用鱼眼相机时,失真可能特别严重。 模拟针孔相机不会考虑这种镜头畸变,因此如果你将其用于直接来自相机的图像,点云将不会与图像完美对齐。 nuScenes 数据集中的图像经过校正,即镜头畸变已被消除,这就是点云与上一节中的图像对齐的原因。

你可以遵循 nuScenes 的方法,在相机校准期间估计镜头畸变(例如遵循前面提到的 OpenCV 教程),然后使用畸变系数使图像不畸变。 然而,当对鱼眼图像进行去畸变时,图像的很大一部分会被丢弃。 因此,在本节中,我们将展示如何使用 Three.js 中的畸变系数来模拟镜头畸变。 这样,我们就可以将 3D 场景直接叠加在扭曲的相机图像上。

5.1 使用着色器实现镜头畸变

在开始编写代码之前,我们首先需要了解失真模型的工作原理以及如何使用着色器来实现它们。 在OpenCV文档中,我们可以找到多种畸变模型。 默认相机模型使用以下畸变系数:

  • k1, ..., k6:用于径向畸变
  • p1, p2:用于切向畸变
  • s1,..., s4:用于薄棱镜畸变
  • tx, ty:用于倾斜图像传感器

只有k1/k2/p1/p2/k3失真系数的镜头模型被称为 Brown-Conrady 或“铅锤”模型。以 Brown (1966) 和 Conrady (1919) 的论文命名。 这是最流行的失真模型,也是我们将在 Three.js 中复现的第一种失真。

我们将复现的第二个失真模型是 OpenCV 文档本页中描述的鱼眼模型。 该模型基于 Kannala-Brandt 模型,该模型可以比 Brown-Conrady 模型更好地模拟广角镜头。 鱼眼相机模型有四个畸变系数:k1, ..., k4。

需要注意的是,两个畸变模型的 OpenCV 文档中的公式都将未畸变点映射到畸变点。

为了在 Three.js 中实现镜头畸变,我们将使用 GLSL(OpenGL 着色器语言)编写一个后处理着色器。 着色器是渲染场景时对每个顶点(= 顶点着色器)或每个像素(= 片段着色器)并行运行的函数。 这种并行执行发生在 GPU 上,GPU 是专门为此类计算而设计的。 通常,不同的着色器用于渲染 3D 场景中不同材质的对象。 对于我们的用例,我们希望在后处理步骤中将镜头畸变着色器应用到整个渲染的 3D 场景。

为了模拟镜头失真,我们可以使用顶点着色器或片段着色器。 使用顶点着色器的优点是我们可以直接使用畸变公式来确定每个顶点在畸变图像中的最终位置。 缺点是顶点之间的边缘保持笔直,而在现实生活中,镜头畸变会使它们弯曲。 如果你正在使用每条边都很短的高分辨率 3D 模型,这可能不是问题。 如果你只想在相机图像上叠加点云,这种方法也很有效(因为没有边缘)。 下表摘自 Lambers 等人的“Realistic Lens Distortion Rendering”。 包含一些进一步的优点和缺点:

顶点着色器片段着色器
畸变模型完整性完全限于径向和切向
先决条件精细的几何形状
结果完整性完全可能有未填充的区域
渲染数据类型全部限于可插值可重定位数据
复杂性取决于几何形状取决于分辨率

在本教程中,我们将使用片段(或像素)着色器来模拟镜头失真。 这种方法的优点是无论 3D 场景中有什么,它都可以工作。 我们还可以通过缩小针孔相机并在着色器中放大来克服未填充区域的问题(请参阅稍后的zoomForDistortionFactor)。

使用片段着色器确实会使着色器的实现变得更加复杂,因为我们不能直接使用 OpenCV 文档中的公式。 要了解原因,你可以想象将着色器应用为在空图像上循环并用特定颜色填充每个像素,如以下伪代码所示:

function applyShader(renderedImage)
  outputImage = new Image(imageWidth, imageHeight)
  
  for i in [0, imageWidth[
    for j in [0, imageHeight[
      outputImage[i, j] = distortionShader(i, j, renderedImage)

因此,片段着色器函数的目的是输出单个像素的颜色,将先前渲染的图像作为输入。 对于镜头畸变,之前渲染的图像是未畸变的3D场景(即我们在针孔相机部分获得的渲染),输出图像应该是畸变的3D场景。 因此,对于输出图像中的每个像素,我们必须找出输入图像中的哪个像素最终到达那里并复制其颜色。 也就是说,给定输出坐标i和j,我们希望找到未扭曲的坐标i'和j'并获取这些未扭曲坐标处的颜色。 你可以看到,这与 OpenCV 页面上的公式相反(因为它们将未扭曲坐标映射到扭曲坐标)。

function distortionShader(i, j, renderedImage)
  iPrime, jPrime = calculateUndistortedCoordinates(i, j)
  return renderedImage[iPrime, jPrime]

现在我们准备为之前介绍的两个失真模型编写实际的 GLSL 着色器。 我不会详细介绍 GLSL 的所有细节。 如果你以前从未编写过着色器,可能需要在继续之前查看 Maxime Heckel 的这篇博客文章,这样你就可以轻松理解代码。

5.2 Brown-Conrady(铅锤)失真

正如上一节所解释的,我们需要找到一种方法来计算着色器中的未扭曲坐标。 对于 Brown-Conrady 模型,我们可以使用“Realistic Lens Distortion Rendering”论文中的公式 2。 这个公式只是一个近似值,并没有使用k3畸变系数。 如果你对更精确的相机模拟感兴趣,可以使用下一节有关鱼眼失真的技术。

uniform sampler2D tDiffuse;
uniform float uCoefficients[5];
uniform vec2 uPrincipalPoint;
uniform vec2 uFocalLength;
uniform float uImageWidth;
uniform float uImageHeight;
uniform float uRelAspect;
uniform float uZoomForDistortionFactor;
varying vec2 vUv;

void main() {
  float relAspectFactorX = max(1.0, uRelAspect);
  float relAspectFactorY = max(1.0, 1.0 / uRelAspect);
  float relAspectOffsetX = ((1.0 - relAspectFactorX) / 2.0);
  float relAspectOffsetY = ((1.0 - relAspectFactorY) / 2.0);
  vec2 inputCoordinatesWithAspectOffset = vec2(vUv.x * relAspectFactorX + relAspectOffsetX, vUv.y * relAspectFactorY + relAspectOffsetY);
  
  float k1 = uCoefficients[0];
  float k2 = uCoefficients[1];
  float p1 = uCoefficients[2];
  float p2 = uCoefficients[3];
  
  vec2 imageCoordinates = (inputCoordinatesWithAspectOffset * vec2(uImageWidth, uImageHeight) - uPrincipalPoint) / uFocalLength;
  float x = imageCoordinates.x;
  float y = imageCoordinates.y;
  float r2 = x * x + y * y;
  float r4 = r2 * r2;
  
  float invFactor = 1.0 / (4.0 * k1 * r2 + 6.0 * k2 * r4 + 8.0 * p1 * y + 8.0 * p2 * x + 1.0);
  float dx = x * (k1 * r2 + k2 * r4) + 2.0 * p1 * x * y + p2 * (r2 + 2.0 * x * x);
  float dy = y * (k1 * r2 + k2 * r4) + p1 * (r2 + 2.0 * y * y) + 2.0 * p2 * x * y;
  x -= invFactor * dx;
  y -= invFactor * dy;
  vec2 coordinates = vec2(x, y);
  
  vec2 principalPointOffset = vec2((uImageWidth / 2.0) - uPrincipalPoint.x, (uImageHeight / 2.0) - uPrincipalPoint.y) * (1.0 - uZoomForDistortionFactor);
  vec2 outputCoordinates = (coordinates * uFocalLength * uZoomForDistortionFactor + uPrincipalPoint + principalPointOffset) / vec2(uImageWidth, uImageHeight);
  
  vec2 coordinatesWithAspectOffset = vec2((outputCoordinates.x - relAspectOffsetX) / relAspectFactorX, (outputCoordinates.y - relAspectOffsetY) / relAspectFactorY);
  gl_FragColor = texture2D(tDiffuse, coordinatesWithAspectOffset);
}

关于着色器代码的一些注释:

  • vUv向量包含伪代码中i和j对应的输出图像坐标。 tDiffuse纹理对应伪代码中的renderedImage,由Three.js自动设置。
  • 我们再次需要 relAspect 来考虑相机图像和浏览器窗口之间的纵横比差异,因为不希望我们的镜头失真被拉伸。
  • 着色器使用称为“UV 坐标”的标准化坐标。 然而,畸变公式适用于像素坐标,因此我们需要将坐标乘以图像的宽度和高度,最后再除以。
  • 我们需要在最后考虑 uZoomForDistortionFactor (用于避免扭曲图像中的未填充区域)。
  • texture2D 函数用于查找(未失真)输入图像中未失真坐标处的颜色。

5.3 鱼眼(Kannala-Brandt)失真

对于鱼眼失真,我们没有可以在着色器中评估的反函数。 相反,我们将使用查找表 (LUT)。 LUT 是一个矩阵,我们可以在其中存储一些预先计算的值。 我们将把未失真的坐标存储在 LUT 中。 在着色器中,我们只需使用扭曲坐标作为索引来“查找”未扭曲坐标即可。

等等,这如何解决我们的问题? 如果没有逆畸变公式,我们如何计算LUT的值呢? 诀窍是使用法线畸变公式将未畸变点映射到畸变点。 我们将这样做:

  • 循环未失真的图像像素。
  • 对于每个像素,使用 OpenCV 文档中的公式计算扭曲坐标。
  • 将未失真坐标保存在 LUT 中失真坐标处。

代码如下:

export interface FisheyeCoefficients {
  k1: number;
  k2: number;
  k3: number;
  k4: number;
}

export function computeFisheyeLUT(
  intrinsicMatrix: Matrix3,
  coefficients: FisheyeCoefficients,
  imageWidth: number,
  imageHeight: number,
  zoomForDistortionFactor: number
) {
  const resolutionOfLUT = 256;
  const rgbaDistortionLUT = Array.from(
    { length: resolutionOfLUT * resolutionOfLUT * 4 },
    () => 0
  );

  const newIntrinsicMatrixInverse =
    computeIntrinsicMatrixInverseWithZoomForDistortion(
      intrinsicMatrix,
      zoomForDistortionFactor,
      imageWidth,
      imageHeight
    );

  const sampleDomainExtension = 0.3;
  const minSampleDomain = 0 - sampleDomainExtension;
  const maxSampleDomain = 1 + sampleDomainExtension;
  const sampleStep = 1 / (resolutionOfLUT * 4);

  for (let i = minSampleDomain; i < maxSampleDomain; i += sampleStep) {
    for (let j = minSampleDomain; j < maxSampleDomain; j += sampleStep) {
      const undistortedCoordinate = { x: i * imageHeight, y: j * imageWidth };

      const { x: distortedX, y: distortedY } = distortCoordinateFisheye(
        undistortedCoordinate,
        intrinsicMatrix,
        coefficients,
        newIntrinsicMatrixInverse
      );

      const distortionLUTIndexX = Math.round(
        (distortedX / imageWidth) * (resolutionOfLUT - 1)
      );

      const distortionLUTIndexY = Math.round(
        (1 - distortedY / imageHeight) * (resolutionOfLUT - 1)
      );

      if (
        distortionLUTIndexX < 0 ||
        distortionLUTIndexX >= resolutionOfLUT ||
        distortionLUTIndexY < 0 ||
        distortionLUTIndexY >= resolutionOfLUT
      ) {
        continue;
      }

      const u = j;
      const v = 1 - i;
      rgbaDistortionLUT[
        distortionLUTIndexY * resolutionOfLUT * 4 + distortionLUTIndexX * 4
      ] = u;
      rgbaDistortionLUT[
        distortionLUTIndexY * resolutionOfLUT * 4 + distortionLUTIndexX * 4 + 1
      ] = v;
      // Blue and Alpha channels will remain 0.
    }
  }

  const distortionLUTData = new Float32Array(rgbaDistortionLUT);
  const distortionLUTTexture = new DataTexture(
    distortionLUTData,
    resolutionOfLUT,
    resolutionOfLUT,
    RGBAFormat,
    FloatType
  );
  distortionLUTTexture.minFilter = LinearFilter;
  distortionLUTTexture.magFilter = LinearFilter;
  distortionLUTTexture.needsUpdate = true;

  return distortionLUTTexture;
}

更多代码注释:

  • 我们不会创建与图像一样大的 LUT,而是使用 256x256 的矩阵。 增加 LUT 大小将提高失真模拟的准确性,但也会增加计算时间和内存使用量。
  • 我们必须再次考虑变焦。
  • 我们将样本域扩展到图像尺寸之外(sampleDomainExtension),因为图像之外的未扭曲点仍然可能会出现在扭曲的图像边界中。
  • 我们使用 DataTexture 将 LUT 传递给着色器。 这也将使我们在着色器中自由进行插值。
interface Coordinate {
  x: number;
  y: number;
}

function distortCoordinateFisheye(
  undistortedCoordinate: Coordinate,
  intrinsicMatrix: Matrix3,
  coefficients: FisheyeCoefficients,
  newIntrinsicMatrixInverse: Matrix3
): Coordinate {
  const { x, y } = undistortedCoordinate;
  const { k1, k2, k3, k4 } = coefficients;

  const fx = intrinsicMatrix.elements[0 + 0 * 3];
  const fy = intrinsicMatrix.elements[1 + 1 * 3];
  const cx = intrinsicMatrix.elements[0 + 2 * 3];
  const cy = intrinsicMatrix.elements[1 + 2 * 3];
  const iR = newIntrinsicMatrixInverse;

  let distortedX: number, distortedY: number;

  const _x =
    x * iR.elements[1 * 3 + 0] +
    y * iR.elements[0 * 3 + 0] +
    iR.elements[2 * 3 + 0];
  const _y =
    x * iR.elements[1 * 3 + 1] +
    y * iR.elements[0 * 3 + 1] +
    iR.elements[2 * 3 + 1];
  const _w =
    x * iR.elements[1 * 3 + 2] +
    y * iR.elements[0 * 3 + 2] +
    iR.elements[2 * 3 + 2];

  if (_w <= 0) {
    distortedX = _x > 0 ? -Infinity : Infinity;
    distortedY = _y > 0 ? -Infinity : Infinity;
  } else {
    const r = Math.sqrt(_x * _x + _y * _y);
    const theta = Math.atan(r);

    const theta2 = theta * theta;
    const theta4 = theta2 * theta2;
    const theta6 = theta4 * theta2;
    const theta8 = theta4 * theta4;
    const theta_d =
      theta * (1 + k1 * theta2 + k2 * theta4 + k3 * theta6 + k4 * theta8);

    const scale = r === 0 ? 1.0 : theta_d / r;
    distortedX = fx * _x * scale + cx;
    distortedY = fy * _y * scale + cy;
  }

  return { x: distortedX, y: distortedY };
}

该函数改编自OpenCV中的initUn DistorifyMap方法。 源代码可以在这里找到。 请注意,本征矩阵和逆本征矩阵彼此不同(即不仅仅是逆矩阵)。 这是因为我们需要考虑后者的 ZoomForDistortionFactor 以及主点偏移。 我们计算这个调整后的逆内参矩阵一次,因为它在整个循环中保持不变。

function computeIntrinsicMatrixInverseWithZoomForDistortion(
  intrinsicMatrix: Matrix3,
  zoomForDistortionFactor: number,
  width: number,
  height: number
) {
  const principalPointOffsetX =
    (width / 2 - intrinsicMatrix.elements[0 + 2 * 3]) *
    (1 - zoomForDistortionFactor);
  const principalPointOffsetY =
    (height / 2 - intrinsicMatrix.elements[1 + 2 * 3]) *
    (1 - zoomForDistortionFactor);

  const newIntrinsicMatrix = [
    [
      intrinsicMatrix.elements[0 + 0 * 3] * zoomForDistortionFactor,
      0,
      intrinsicMatrix.elements[0 + 2 * 3] + principalPointOffsetX,
    ],
    [
      0,
      intrinsicMatrix.elements[1 + 1 * 3] * zoomForDistortionFactor,
      intrinsicMatrix.elements[1 + 2 * 3] + principalPointOffsetY,
    ],
    [0, 0, 1],
  ];

  const newIntrinsicMatrixInverse = new Matrix3()
    .fromArray(newIntrinsicMatrix.flat())
    .transpose()
    .invert();

  return newIntrinsicMatrixInverse;
}

最后,我们可以实现鱼眼失真着色器本身。 这个非常简单,因为它只需要在 LUT 中查找未失真的坐标即可。 然而,与 Brown-Conrady 着色器中相同的标准化是必要的。

uniform sampler2D tDiffuse;
uniform sampler2D uDistortionLUT;
uniform float uRelAspect;
varying vec2 vUv;

void main() {
  float relAspectFactorX = max(1.0, uRelAspect);
  float relAspectFactorY = max(1.0, 1.0 / uRelAspect);
  float relAspectOffsetX = ((1.0 - relAspectFactorX) / 2.0);
  float relAspectOffsetY = ((1.0 - relAspectFactorY) / 2.0);
  vec2 inputCoordinatesWithAspectOffset = vec2(vUv.x * relAspectFactorX + relAspectOffsetX , vUv.y * relAspectFactorY + relAspectOffsetY);

  // discard pixels on the edge to avoid streaking
  float threshold = 0.001;
  if (
    inputCoordinatesWithAspectOffset.x <= 0.0 + threshold ||
    inputCoordinatesWithAspectOffset.x >= 1.0 - threshold ||
    inputCoordinatesWithAspectOffset.y <= 0.0 + threshold ||
    inputCoordinatesWithAspectOffset.y >= 1.0 - threshold
  ) {
    // show black overlay
    gl_FragColor = vec4(0.0, 0.0, 0.0, 0.4);
    return;
  }

  // look up distortion in LUT
  vec2 outputCoordinates = texture2D(uDistortionLUT, inputCoordinatesWithAspectOffset).rg;
  if (outputCoordinates.x == 0.0 && outputCoordinates.y == 0.0) {
    // show black overlay
    gl_FragColor = vec4(0.0, 0.0, 0.0, 0.4);
    return;
  }
  
  vec2 coordinatesWithAspectOffset = vec2((outputCoordinates.x - relAspectOffsetX) / relAspectFactorX, (outputCoordinates.y - relAspectOffsetY) / relAspectFactorY);        
  gl_FragColor = texture2D(tDiffuse, coordinatesWithAspectOffset);
}

两个小注意事项:

  • 扭曲图像边缘上的像素将在图像左/右或上方/下方的边缘上重复。 为了避免条纹效果,我们将这些边框像素设置为不透明度为 40% 的黑色叠加层。
  • 如果 LUT 中的值为零,则意味着它可能没有被填充,因此我们忽略这些像素并简单地返回黑色覆盖层。

5.4 在 Three.js 中实现后处理着色器

现在我们有了着色器,是时候在后处理过程中使用它们了。 要使用该通道,我们首先将场景渲染到“渲染目标”(缓冲区),然后将通道应用于该渲染目标,最后将结果渲染到屏幕上。

为了设置这个管道,我们将使用 Three.js 中的 EffectComposer。 将场景渲染到渲染目标是通过使用 RenderPass 来实现的。 我们还需要调整动画函数。

...
const composer = new EffectComposer(renderer);

const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);
composer.setPixelRatio(1 / zoomForDistortionFactor);

function animate() {
  requestAnimationFrame(animate);
  composer.render();
}

animate();

现在我们需要为扭曲着色器创建一个通道。 为此,我们可以使用 Three.js 中的 ShaderPass。 之后,我们可以使用传递中存在的uniforms对象将变量传递给我们的自定义着色器。

Brown-Conrady 失真的着色器通道设置:

const distortionPass = new ShaderPass(BrownConradyDistortionShader);
distortionPass.uniforms.uCoefficients.value = [
  distortionCoefficients.k1,
  distortionCoefficients.k2,
  distortionCoefficients.p1,
  distortionCoefficients.p2,
  distortionCoefficients.k3,
];
distortionPass.uniforms.uPrincipalPoint.value = new Vector2(
  matrixK.elements[0 + 2 * 3],
  matrixK.elements[1 + 2 * 3]
);
distortionPass.uniforms.uFocalLength.value = new Vector2(
  matrixK.elements[0 + 0 * 3],
  matrixK.elements[1 + 1 * 3]
);
distortionPass.uniforms.uImageWidth.value = imageWidth;
distortionPass.uniforms.uImageHeight.value = imageHeight;
distortionPass.uniforms.uZoomForDistortionFactor.value =
  zoomForDistortionFactor;
distortionPass.uniforms.uRelAspect.value =
  window.innerWidth / window.innerHeight / (imageWidth / imageHeight);
composer.addPass(distortionPass);

使用 Brown-Conrady 畸变系数更新calibration.json 并在 PinholeCamera 中实现 ZoomForDistortionFactor 后,我们现在可以将点云覆盖在原始未畸变图像上。

鱼眼失真的着色器通道设置:

const distortionPass = new ShaderPass(FisheyeDistortionShader);
const distortionLUTTexture = computeFisheyeLUT(
  matrixK,
  distortionCoefficients,
  imageWidth,
  imageHeight,
  zoomForDistortionFactor
);
distortionPass.uniforms.uDistortionLUT.value = distortionLUTTexture;
distortionPass.uniforms.uRelAspect.value =
  window.innerWidth / window.innerHeight / (imageWidth / imageHeight);
composer.addPass(distortionPass);

6、结束语

总之,模拟真实相机使我们能够以逼真的方式将 3D 场景叠加在相机图像上。 在这篇博文中,我们向你展示了如何在 Three.js 中模拟针孔相机模型,并通过使用后处理着色器实现 OpenCV 的畸变模型来添加真实的镜头畸变。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/511226.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

【Java 集合进阶】单练集合顶层接口collction迭代器

&#x1f36c; 博主介绍&#x1f468;‍&#x1f393; 博主介绍&#xff1a;大家好&#xff0c;我是 hacker-routing &#xff0c;很高兴认识大家~ ✨主攻领域&#xff1a;【渗透领域】【应急响应】 【Java】 【VulnHub靶场复现】【面试分析】 &#x1f389;点赞➕评论➕收藏 …

适合初学者的Linux的综合项目

大家好&#xff0c;今天给大家介绍适合初学者的Linux的综合项目&#xff0c;文章末尾附有分享大家一个资料包&#xff0c;差不多150多G。里面学习内容、面经、项目都比较新也比较全&#xff01;可进群免费领取。 对于初学者来说&#xff0c;Linux的综合项目应当既具有教育意义又…

element plus 输入框样式模仿Material-UI

获取焦点状态 自定义指令 app.directive(focus, { // 当被绑定的元素插入到 DOM 中时…… mounted(el) { const descendants el.querySelectorAll(.el-input__inner); var cssClass newLable;el.classList.add(cssClass); // 遍历并操作这些子孙节点 descendants.forE…

(24年4月2日更新)Linux安装chrome及chromedriver(Ubuntu20.0416.04)

一、安装Chrome 1&#xff09;先执行命令下载chrome&#xff1a; wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb2&#xff09;安装chrome sudo dpkg -i google-chrome-stable_current_amd64.deb踩坑&#xff1a;这里会提示如下报错&…

安卓主板MT8390(Genio 700)_MTK联发科Linux开发板方案

MediaTek Genio 700 &#xff08;MT8390&#xff09;是一款高性能的边缘 AI 物联网平台&#xff0c;专为智能家居、互动零售、工业与商业应用而设计。提供快速响应的边缘计算能力、先进的多媒体功能、广泛的传感器和连接方式&#xff0c;且支持多任务操作系统。 MT8390安卓核心…

ArrayList扩容原理

ArrayList源码分析 分析ArrayList源码主要从三个方面去翻阅&#xff1a;成员变量&#xff0c;构造函数&#xff0c;关键方法 以下源码都来源于jdk1.8 1 成员变量 DEFAULT_CAPACITY 10; 默认初始的容量**(CAPACITY) EMPTY_ELEMENTDATA {}; 用于空实例的共享空数组实例 DEFAU…

Java项目:85 springboot智能物流管理系统

作者主页&#xff1a;舒克日记 简介&#xff1a;Java领域优质创作者、Java项目、学习资料、技术互助 文中获取源码 项目介绍 本美发门店管理系统有管理员和用户两个角色。 用户功能有项目预定管理&#xff0c;产品购买管理&#xff0c;会员充值管理&#xff0c;余额查询管理。…

文本自动粘贴编辑器:支持自动粘贴并筛选手机号码,让信息处理更轻松

在信息时代的浪潮中&#xff0c;文本处理已成为我们日常工作与生活的重要组成部分。无论是商务沟通、社交互动还是个人事务处理&#xff0c;手机号码的筛选与粘贴都显得尤为关键。然而&#xff0c;传统的文本处理方式效率低下、易出错&#xff0c;已无法满足现代人的高效需求。…

Linux(05) Debian 系统修改主机名

查看主机名 方法1&#xff1a;hostname hostname 方法2&#xff1a;cat etc/hostname cat /etc/hostname 如果在创建Linux系统的时候忘记修改主机名&#xff0c;可以采用以下的方式来修改主机名称。 修改主机名 注意&#xff0c;在linux中下划线“_”可能是无效的字符&…

disearch目录扫描工具

项目地址 GitHub - maurosoria/dirsearch: Web path scanner 安装 apt-get install dirsearch 使用 dirsearch -u http://61.147.171.105:56237/

网络协议学习——HTTPS

目录 ​编辑 一&#xff0c;认识HTTPS 二&#xff0c;加密方式 1&#xff0c;对称式加密 2&#xff0c;非对称式的加密 3&#xff0c;数据指纹&#xff08;数据摘要&#xff09; 4&#xff0c;数据签名 三&#xff0c;HTTPS的工作原理 实现方式 数字证书 一&#xff0c…

配mmdetection

总流程&#xff1a; 1. 安装conda 参考链接后面补上 列出可用的conda环境 conda env list 删除指定环境 conda remove --name myenv --all 创建并激活指定环境 conda create --name openmmlab python3.8 -y conda activate openmmlab 2. 装pytorch&#xff0c;版本别装错…

zabbix图表时间与服务器时间不一致问题

部署完zabbix后&#xff0c;有时候会发现zabbix服务器的时间明明是对的&#xff0c;但是图标的时间不对&#xff0c;通过以下的配置可以快速解决。 登录zabbix-nginx容器 docker exec -u root -it docker-compose-zabbix-zabbix-web-nginx-mysql-1 bash修改php配置文件 vi /e…

excel散点图怎么每个点添加名称

最终效果图&#xff1a; 添加图标元素->数据标签->其他数据标签选项 选择单元格中的值 手动拖动数据标签&#xff0c;调整到合适的位置。

javaweb学习(day11-监听器Listener过滤器Filter)

一、监听器Listener 1 Listener介绍 Listener 监听器它是 JavaWeb 的三大组件之一。JavaWeb 的三大组件分别是&#xff1a;Servlet 程 序、Listener 监听器、Filter 过滤器 Listener 是 JavaEE 的规范&#xff0c;就是接口 监听器的作用是&#xff0c;监听某种变化(一般就是对…

RISC-V GNU Toolchain 工具链安装问题解决(含 stdio.h 问题解决)

我的安装过程主要参照 riscv-collab/riscv-gnu-toolchain 的官方 Readme 和这位佬的博客&#xff1a;RSIC-V工具链介绍及其安装教程 - 风正豪 &#xff08;大佬的博客写的非常详细&#xff0c;唯一不足就是 sudo make linux -jxx 是全部小写。&#xff09; 工具链前前后后我装了…

搜维尔科技:SenseGlove Nova 允许以最简单的方式操作机器人并与物体交互

扩展 Robotics 和 QuarkXR 人机界面 XR 应用 Extend Robotics 利用扩展现实技术&#xff0c;让没有机器人专业知识的个人能够远程控制机器人。他们的 AMAS 解决方案使操作员能够不受地理限制地轻松控制机器人。 需要解决的挑战【搜维尔科技】 目前&#xff0c;操作机器人是一…

day4|gin的中间件和路由分组

中间件其实是一个方法&#xff0c; 在.use就可以调用中间件函数 r : gin.Default()v1 : r.Group("v1")//v1 : r.Group("v1").Use()v1.GET("test", func(c *gin.Context) {fmt.Println("get into the test")c.JSON(200, gin.H{"…

Git指令速查

一、Git初始化 作用&#xff1a;初始化git仓库&#xff0c;想要使用git对某个项目进行管理&#xff0c;需要git init进行初始化 # 在当前目录新建一个Git代码库&#xff0c;初始化仓库。 在当前目录下生成一个隐藏文件夹.git&#xff0c;不能修改.git下的任何东西 $ git ini…

jmeter性能压测

jvm指令 jstat -gcutil -h5 -t 1 3s 发压端的tcp这么达到1000TPS jmeter的jvm的设置