Three.js真实相机畸变效果模拟

news2024/12/25 9:30:43

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

在这里插入图片描述

推荐:用 NSDT编辑器 快速搭建可编程3D场景

拥有逼真的模拟相机可以让你在真实相机捕获的图像上渲染 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 的畸变模型来添加真实的镜头畸变。


原文链接:Three.js真实相机模拟 — BimAnt

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【C++】Stack Queue -- 详解

一、stack的介绍和使用 1、stack的介绍 https://cplusplus.com/reference/stack/stack/?kwstack 1. stack 是一种容器适配器&#xff0c;专门用在具有后进先出操作的上下文环境中&#xff0c;其删除只能从容器的一端进行元素的插入与提取操作。 2. stack 是作为容器适配器被…

改变世界-生成式人工智能

麦肯锡在其《生成人工智能的经济潜力&#xff1a;下一个生产力前沿》中声称&#xff0c;“ChatGPT、GitHub Copilot、Stable Diffusion 等生成式人工智能应用程序以 AlphaGo 没有的方式吸引了世界各地人们的想象力&#xff0c;这要归功于它们广泛的实用性——几乎任何人都可以使…

[mysql工具]Windows批处理方式实现MySQL定期自动备份

Windows批处理方式实现MySQL定期自动备份 对MySQL数据库而言&#xff0c;大部分数据库工具都具有备份功能&#xff0c;但并不能做到定期自动备份&#xff0c;在Windows环境下&#xff0c;手工备份MySQL是很繁琐的&#xff0c;所以我们通过MySQL提供的备份命令mysqldump&#xf…

如何防止重复提交订单

产生的原因 一种是由于用户在短时间内多次点击下单按钮&#xff0c;或浏览器刷新按钮导致。另一种则是由于Nginx或类似于SpringCloud Gateway的网关层&#xff0c;进行超时重试造成的。由于网速等原因造成页面卡顿&#xff0c;用户重复刷新提交页面黑客或恶意用户使用 postman…

maven配置代理

1.找到文件 find / -name "settings.xml" 当 maven 无法正常访问网络时候&#xff0c;需要通过代理进行访问 找到Maven的setting.conf文件 2.找到proxies 在maven的 setting.conf文件中找到 默认找到的时候文件 这里是被注释的。 3.配置如下 3.1配置截图 <…

QML 带框最大化显示方法

1.QML窗口最大化很多会给出如下方法: visibility: "FullScreen" 此方法不好的方面是没有最大化&#xff0c;最小化&#xff0c;关闭按钮 2.通过showMaximized() 方法可以满足我们需求:在onCompleted 方法中执行 实现的效果如下:

前后端分离计算机毕设项目之基于SpringBoot的无人智慧超市管理系统的设计与实现《内含源码+文档+部署教程》

博主介绍&#xff1a;✌全网粉丝10W,前互联网大厂软件研发、集结硕博英豪成立工作室。专注于计算机相关专业毕业设计项目实战6年之久&#xff0c;选择我们就是选择放心、选择安心毕业✌ &#x1f345;由于篇幅限制&#xff0c;想要获取完整文章或者源码&#xff0c;或者代做&am…

浏览器自动化神器:Automa 轻松实现任务编排 | 开源日报 No.52

usememos/memos Stars: 13.8k License: MIT memos&#xff0c;一个轻量级的、自托管的备忘录中心。开源且永久免费。 开源且永久免费使用 Docker 可以在几秒钟内完成自我托管支持 Markdown 格式可定制和共享提供 RESTful API 用于自助服务 mamoe/mirai Stars: 12.6k Licen…

2023.10.7 Java 创建线程的七种方法

目录 继承 Tread 类&#xff0c;重写 run 方法 实现 Runnable 接口 使用匿名内部类&#xff0c;继承 Thread 类 使用匿名内部类&#xff0c;实现 Runable 接口 使用 Lambda 表达式 使用线程池创建线程 实现 Callable 接口 继承 Tread 类&#xff0c;重写 run 方法 自定…

uni-app项目成功编译到微信开发者工具出现警告:当前组件仅支持 uni_modules 目录结构 ,请升级 HBuilderX 到 3.1.0 版本以上!

问题描述 为什么uni-app项目编译成功后&#xff0c;运行到微信开发者工具&#xff0c;却出现警告&#xff1a;当前组件仅支持 uni_modules 目录结构 &#xff0c;请升级 HBuilderX 到 3.1.0 版本以上&#xff01; 初识uni-app的童鞋&#xff0c;经常会问&#xff1a;我使用HBui…

2019年[海淀区赛 第2题] 阶乘

题目描述 n的阶乘定义为n!n*(n -1)* (n - 2)* ...* 1。n的双阶乘定义为n!!n*(n -2)* (n -4)* ...* 2或n!!n(n - 2)*(n - 4)* ...* 1取决于n的奇偶性&#xff0c;但是阶乘的增长速度太快了&#xff0c;所以我们现在只想知道n!和n!!末尾的的个数 输入格式 一个正整数n &#xff…

酷开会员 | 亚运会来啦!酷开系统陪你一起看赛事!

第十九届亚洲运动会已经开始啦&#xff01;坐标杭州&#xff0c;本次亚运会有来自亚洲45个国家和地区的1.2万余名运动员参赛&#xff0c;是史上规模最大、覆盖面最广的一届亚运会。它是亚洲具有世界性影响的体育盛会&#xff0c;来自亚洲各国和地区的运动员在赛场上奋力拼搏&am…

南美阿根廷市场最全分析开发攻略,收藏一篇就够了

聊到阿根廷&#xff0c;大家可能对阿根廷的足球印象比较深&#xff0c;比如球星梅西&#xff0c;不管是不是球迷应该大部分都有听说过&#xff0c;阿根廷作为南美洲面积第二大的国家&#xff0c;市场潜力也是非常不错的&#xff0c;今天就主要来聊一下关于阿根廷市场的一些相关…

工作流程引擎有几个特点?可以提高办公效率吗?

如果想要实现高效率的自动化办公&#xff0c;还依靠传统的办公软件是没有办法实现的。在自动化发展程度越来越高的今天&#xff0c;职场办公也拥有了优质的办公软件&#xff0c;助力实现高效率办公。低代码技术平台是专业的企业级应用低代码平台&#xff0c;其中的工作流程引擎…

深入理解树状数组 | 京东物流技术团队

树状数组 树状数组&#xff08;BIT, Binary Indexed Tree&#xff09;是简洁优美的数据结构&#xff0c;它能在很少的代码量下支持单点修改和区间查询&#xff0c;我们先以a[] {1, 2, 3, 4, 5, 6}数组为例建立树状数组看一下树状数组的样子&#xff1a; 可以发现&#xff1a;不…

websocket协议 | http协议

文章目录 一、前言二、websocket协议2.1 怎么建立websocket连接 三、HTTP协议3.1 特点3.2 报文格式3.3 连接方式三次握手四次挥手 3.4 版本HTTP 1.0HTTP 1.1 3.1 http长轮询场景&#xff1a;扫码登陆 四、二者比较4.1 相同4.2 区别1.通讯方式不同2.通信效率3.数据格式 一、前言…

XD 文件怎么打开,一分钟快速搞定

Adobe XD 是一款强大的用户界面和用户体验设计工具&#xff0c;广泛用于创建交互式原型、网站和移动应用程序&#xff0c;其中包含设计的所有元素和交互信息。 如果你拿到.xd 文件&#xff0c;却没有安装 Adobe XD 软件&#xff0c;下载安装步骤也很繁琐&#xff0c;纠结如何打…

LeetCode刷题笔记【34】:动态规划专题-6(完全背包、零钱兑换II、组合总合IV)

文章目录 前置知识经典完全背包问题(纯完全背包问题)题目描述解题思路如何实现"物品可以被多次添加"?遍历物品和遍历背包容量(内外层遍历)能否调换? 代码 518. 零钱兑换 II题目描述解题思路初始化&递推公式内层for循环顺序内外层for循环的顺序(先遍历物品还是先…

聊聊电商系统架构演进

具体以电子商务网站为例&#xff0c; 展示web应用的架构演变过程。 1.0时代 这个时候是一个web项目里包含了所有的模块&#xff0c;一个数据库里包含了所需要的所有表&#xff0c;这时候网站访问量增加时&#xff0c;首先遇到瓶颈的是应用服务器连接数&#xff0c;比如tomcat连…

含叠氮的代谢糖蛋白标记试剂361154-30-5,N -叠氮乙酰基甘露糖胺-四酰基化

产品简介&#xff1a;N-叠氮乙酰基甘露糖胺-三酰化&#xff08;AC4MANAZ&#xff09;可用作标记试剂&#xff0c;点击糖化学试剂&#xff0c;叠氮化物基团允许它与炔烃反应&#xff0c;是一种含叠氮的代谢糖蛋白标记试剂&#xff0c;叠氮化物修饰的蛋白质可以通过与炔烃反应检测…