mapillary-js
Version:
A WebGL interactive street imagery library
723 lines (636 loc) • 23.6 kB
text/typescript
import * as THREE from "three";
import { isFisheye, isSpherical } from "./Geo";
import { CameraType } from "./interfaces/CameraType";
const EPSILON = 1e-8;
/**
* @class Transform
*
* @classdesc Class used for calculating coordinate transformations
* and projections.
*/
export class Transform {
private _width: number;
private _height: number;
private _focal: number;
private _orientation: number;
private _scale: number;
private _basicWidth: number;
private _basicHeight: number;
private _basicAspect: number;
private _worldToCamera: THREE.Matrix4;
private _worldToCameraInverse: THREE.Matrix4;
private _scaledWorldToCamera: THREE.Matrix4;
private _scaledWorldToCameraInverse: THREE.Matrix4;
private _basicWorldToCamera: THREE.Matrix4;
private _textureScale: number[];
private _ck1: number;
private _ck2: number;
private _cameraType: CameraType;
private _radialPeak: number;
/**
* Create a new transform instance.
* @param {number} orientation - Image orientation.
* @param {number} width - Image height.
* @param {number} height - Image width.
* @param {number} focal - Focal length.
* @param {number} scale - Atomic scale.
* @param {Array<number>} rotation - Rotation vector in three dimensions.
* @param {Array<number>} translation - Translation vector in three dimensions.
* @param {HTMLImageElement} image - Image for fallback size calculations.
*/
constructor(
orientation: number,
width: number,
height: number,
scale: number,
rotation: number[],
translation: number[],
image: HTMLImageElement,
textureScale?: number[],
cameraParameters?: number[],
cameraType?: CameraType) {
this._orientation = this._getValue(orientation, 1);
let imageWidth = image != null ? image.width : 4;
let imageHeight = image != null ? image.height : 3;
let keepOrientation = this._orientation < 5;
this._width = this._getValue(width, keepOrientation ? imageWidth : imageHeight);
this._height = this._getValue(height, keepOrientation ? imageHeight : imageWidth);
this._basicAspect = keepOrientation ?
this._width / this._height :
this._height / this._width;
this._basicWidth = keepOrientation ? width : height;
this._basicHeight = keepOrientation ? height : width;
const parameters = this._getCameraParameters(
cameraParameters,
cameraType);
const focal = parameters[0];
const ck1 = parameters[1];
const ck2 = parameters[2];
this._focal = this._getValue(focal, 1);
this._scale = this._getValue(scale, 0);
this._worldToCamera = this.createWorldToCamera(rotation, translation);
this._worldToCameraInverse = new THREE.Matrix4()
.copy(this._worldToCamera)
.invert()
this._scaledWorldToCamera =
this._createScaledWorldToCamera(this._worldToCamera, this._scale);
this._scaledWorldToCameraInverse = new THREE.Matrix4()
.copy(this._scaledWorldToCamera)
.invert();
this._basicWorldToCamera = this._createBasicWorldToCamera(
this._worldToCamera,
orientation);
this._textureScale = !!textureScale ? textureScale : [1, 1];
this._ck1 = !!ck1 ? ck1 : 0;
this._ck2 = !!ck2 ? ck2 : 0;
this._cameraType = !!cameraType ?
cameraType :
"perspective";
this._radialPeak = this._getRadialPeak(this._ck1, this._ck2);
}
public get ck1(): number {
return this._ck1;
}
public get ck2(): number {
return this._ck2;
}
public get cameraType(): CameraType {
return this._cameraType;
}
/**
* Get basic aspect.
* @returns {number} The orientation adjusted aspect ratio.
*/
public get basicAspect(): number {
return this._basicAspect;
}
/**
* Get basic height.
*
* @description Does not fall back to image image height but
* uses original value from API so can be faulty.
*
* @returns {number} The height of the basic version image
* (adjusted for orientation).
*/
public get basicHeight(): number {
return this._basicHeight;
}
public get basicRt(): THREE.Matrix4 {
return this._basicWorldToCamera;
}
/**
* Get basic width.
*
* @description Does not fall back to image image width but
* uses original value from API so can be faulty.
*
* @returns {number} The width of the basic version image
* (adjusted for orientation).
*/
public get basicWidth(): number {
return this._basicWidth;
}
/**
* Get focal.
* @returns {number} The image focal length.
*/
public get focal(): number {
return this._focal;
}
/**
* Get height.
*
* @description Falls back to the image image height if
* the API data is faulty.
*
* @returns {number} The orientation adjusted image height.
*/
public get height(): number {
return this._height;
}
/**
* Get orientation.
* @returns {number} The image orientation.
*/
public get orientation(): number {
return this._orientation;
}
/**
* Get rt.
* @returns {THREE.Matrix4} The extrinsic camera matrix.
*/
public get rt(): THREE.Matrix4 {
return this._worldToCamera;
}
/**
* Get srt.
* @returns {THREE.Matrix4} The scaled extrinsic camera matrix.
*/
public get srt(): THREE.Matrix4 {
return this._scaledWorldToCamera;
}
/**
* Get srtInverse.
* @returns {THREE.Matrix4} The scaled extrinsic camera matrix.
*/
public get srtInverse(): THREE.Matrix4 {
return this._scaledWorldToCameraInverse;
}
/**
* Get scale.
* @returns {number} The image atomic reconstruction scale.
*/
public get scale(): number {
return this._scale;
}
/**
* Get has valid scale.
* @returns {boolean} Value indicating if the scale of the transform is valid.
*/
public get hasValidScale(): boolean {
return this._scale > 1e-2 && this._scale < 50;
}
/**
* Get radial peak.
* @returns {number} Value indicating the radius where the radial
* undistortion function peaks.
*/
public get radialPeak(): number {
return this._radialPeak;
}
/**
* Get width.
*
* @description Falls back to the image image width if
* the API data is faulty.
*
* @returns {number} The orientation adjusted image width.
*/
public get width(): number {
return this._width;
}
/**
* Calculate the up vector for the image transform.
*
* @returns {THREE.Vector3} Normalized and orientation adjusted up vector.
*/
public upVector(): THREE.Vector3 {
let rte: number[] = this._worldToCamera.elements;
switch (this._orientation) {
case 1:
return new THREE.Vector3(-rte[1], -rte[5], -rte[9]);
case 3:
return new THREE.Vector3(rte[1], rte[5], rte[9]);
case 6:
return new THREE.Vector3(-rte[0], -rte[4], -rte[8]);
case 8:
return new THREE.Vector3(rte[0], rte[4], rte[8]);
default:
return new THREE.Vector3(-rte[1], -rte[5], -rte[9]);
}
}
/**
* Calculate projector matrix for projecting 3D points to texture map
* coordinates (u and v).
*
* @returns {THREE.Matrix4} Projection matrix for 3D point to texture
* map coordinate calculations.
*/
public projectorMatrix(): THREE.Matrix4 {
let projector: THREE.Matrix4 = this._normalizedToTextureMatrix();
let f: number = this._focal;
let projection: THREE.Matrix4 = new THREE.Matrix4().set(
f, 0, 0, 0,
0, f, 0, 0,
0, 0, 0, 0,
0, 0, 1, 0);
projector.multiply(projection);
projector.multiply(this._worldToCamera);
return projector;
}
/**
* Project 3D world coordinates to basic coordinates.
*
* @param {Array<number>} point3d - 3D world coordinates.
* @return {Array<number>} 2D basic coordinates.
*/
public projectBasic(point3d: number[]): number[] {
let sfm: number[] = this.projectSfM(point3d);
return this._sfmToBasic(sfm);
}
/**
* Unproject basic coordinates to 3D world coordinates.
*
* @param {Array<number>} basic - 2D basic coordinates.
* @param {Array<number>} distance - Distance to unproject from camera center.
* @param {boolean} [depth] - Treat the distance value as depth from camera center.
* Only applicable for perspective images. Will be
* ignored for spherical.
* @returns {Array<number>} Unprojected 3D world coordinates.
*/
public unprojectBasic(basic: number[], distance: number, depth?: boolean): number[] {
let sfm: number[] = this._basicToSfm(basic);
return this.unprojectSfM(sfm, distance, depth);
}
/**
* Project 3D world coordinates to SfM coordinates.
*
* @param {Array<number>} point3d - 3D world coordinates.
* @return {Array<number>} 2D SfM coordinates.
*/
public projectSfM(point3d: number[]): number[] {
let v: THREE.Vector4 = new THREE.Vector4(point3d[0], point3d[1], point3d[2], 1);
v.applyMatrix4(this._worldToCamera);
return this._bearingToSfm([v.x, v.y, v.z]);
}
/**
* Unproject SfM coordinates to a 3D world coordinates.
*
* @param {Array<number>} sfm - 2D SfM coordinates.
* @param {Array<number>} distance - Distance to unproject
* from camera center.
* @param {boolean} [depth] - Treat the distance value as
* depth from camera center. Only applicable for perspective
* images. Will be ignored for spherical.
* @returns {Array<number>} Unprojected 3D world coordinates.
*/
public unprojectSfM(
sfm: number[],
distance: number,
depth?: boolean): number[] {
const bearing = this._sfmToBearing(sfm);
const unprojectedCamera = depth && !isSpherical(this._cameraType) ?
new THREE.Vector4(
distance * bearing[0] / bearing[2],
distance * bearing[1] / bearing[2],
distance,
1) :
new THREE.Vector4(
distance * bearing[0],
distance * bearing[1],
distance * bearing[2],
1);
const unprojectedWorld = unprojectedCamera
.applyMatrix4(this._worldToCameraInverse);
return [
unprojectedWorld.x / unprojectedWorld.w,
unprojectedWorld.y / unprojectedWorld.w,
unprojectedWorld.z / unprojectedWorld.w,
];
}
/**
* Transform SfM coordinates to bearing vector (3D cartesian
* coordinates on the unit sphere).
*
* @param {Array<number>} sfm - 2D SfM coordinates.
* @returns {Array<number>} Bearing vector (3D cartesian coordinates
* on the unit sphere).
*/
private _sfmToBearing(sfm: number[]): number[] {
if (isSpherical(this._cameraType)) {
let lng: number = sfm[0] * 2 * Math.PI;
let lat: number = -sfm[1] * 2 * Math.PI;
let x: number = Math.cos(lat) * Math.sin(lng);
let y: number = -Math.sin(lat);
let z: number = Math.cos(lat) * Math.cos(lng);
return [x, y, z];
} else if (isFisheye(this._cameraType)) {
let [dxn, dyn]: number[] = [sfm[0] / this._focal, sfm[1] / this._focal];
const dTheta: number = Math.sqrt(dxn * dxn + dyn * dyn);
let d: number = this._distortionFromDistortedRadius(dTheta, this._ck1, this._ck2, this._radialPeak);
let theta: number = dTheta / d;
let z: number = Math.cos(theta);
let r: number = Math.sin(theta);
const denomTheta = dTheta > EPSILON ? 1 / dTheta : 1;
let x: number = r * dxn * denomTheta;
let y: number = r * dyn * denomTheta;
return [x, y, z];
} else {
let [dxn, dyn]: number[] = [sfm[0] / this._focal, sfm[1] / this._focal];
const dr: number = Math.sqrt(dxn * dxn + dyn * dyn);
let d: number = this._distortionFromDistortedRadius(dr, this._ck1, this._ck2, this._radialPeak);
const xn: number = dxn / d;
const yn: number = dyn / d;
let v: THREE.Vector3 = new THREE.Vector3(xn, yn, 1);
v.normalize();
return [v.x, v.y, v.z];
}
}
/** Compute distortion given the distorted radius.
*
* Solves for d in the equation
* y = d(x, k1, k2) * x
* given the distorted radius, y.
*/
private _distortionFromDistortedRadius(distortedRadius: number, k1: number, k2: number, radialPeak: number): number {
let d: number = 1.0;
for (let i: number = 0; i < 10; i++) {
let radius: number = distortedRadius / d;
if (radius > radialPeak) {
radius = radialPeak;
}
d = 1 + k1 * radius ** 2 + k2 * radius ** 4;
}
return d;
}
/**
* Transform bearing vector (3D cartesian coordiantes on the unit sphere) to
* SfM coordinates.
*
* @param {Array<number>} bearing - Bearing vector (3D cartesian coordinates on the
* unit sphere).
* @returns {Array<number>} 2D SfM coordinates.
*/
private _bearingToSfm(bearing: number[]): number[] {
if (isSpherical(this._cameraType)) {
let x: number = bearing[0];
let y: number = bearing[1];
let z: number = bearing[2];
let lng: number = Math.atan2(x, z);
let lat: number = Math.atan2(-y, Math.sqrt(x * x + z * z));
return [lng / (2 * Math.PI), -lat / (2 * Math.PI)];
} else if (isFisheye(this._cameraType)) {
if (bearing[2] > 0) {
const [x, y, z]: number[] = bearing;
const r: number = Math.sqrt(x * x + y * y);
let theta: number = Math.atan2(r, z);
if (theta > this._radialPeak) {
theta = this._radialPeak;
}
const distortion: number = 1.0 + theta ** 2 * (this._ck1 + theta ** 2 * this._ck2);
const s: number = this._focal * distortion * theta / r;
return [s * x, s * y];
} else {
return [
bearing[0] < 0 ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY,
bearing[1] < 0 ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY,
];
}
} else {
if (bearing[2] > 0) {
let [xn, yn]: number[] = [bearing[0] / bearing[2], bearing[1] / bearing[2]];
let r2: number = xn * xn + yn * yn;
const rp2: number = this._radialPeak ** 2;
if (r2 > rp2) {
r2 = rp2;
}
const d: number = 1 + this._ck1 * r2 + this._ck2 * r2 ** 2;
return [
this._focal * d * xn,
this._focal * d * yn,
];
} else {
return [
bearing[0] < 0 ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY,
bearing[1] < 0 ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY,
];
}
}
}
/**
* Convert basic coordinates to SfM coordinates.
*
* @param {Array<number>} basic - 2D basic coordinates.
* @returns {Array<number>} 2D SfM coordinates.
*/
private _basicToSfm(basic: number[]): number[] {
let rotatedX: number;
let rotatedY: number;
switch (this._orientation) {
case 1:
rotatedX = basic[0];
rotatedY = basic[1];
break;
case 3:
rotatedX = 1 - basic[0];
rotatedY = 1 - basic[1];
break;
case 6:
rotatedX = basic[1];
rotatedY = 1 - basic[0];
break;
case 8:
rotatedX = 1 - basic[1];
rotatedY = basic[0];
break;
default:
rotatedX = basic[0];
rotatedY = basic[1];
break;
}
let w: number = this._width;
let h: number = this._height;
let s: number = Math.max(w, h);
let sfmX: number = rotatedX * w / s - w / s / 2;
let sfmY: number = rotatedY * h / s - h / s / 2;
return [sfmX, sfmY];
}
/**
* Convert SfM coordinates to basic coordinates.
*
* @param {Array<number>} sfm - 2D SfM coordinates.
* @returns {Array<number>} 2D basic coordinates.
*/
private _sfmToBasic(sfm: number[]): number[] {
let w: number = this._width;
let h: number = this._height;
let s: number = Math.max(w, h);
let rotatedX: number = (sfm[0] + w / s / 2) / w * s;
let rotatedY: number = (sfm[1] + h / s / 2) / h * s;
let basicX: number;
let basicY: number;
switch (this._orientation) {
case 1:
basicX = rotatedX;
basicY = rotatedY;
break;
case 3:
basicX = 1 - rotatedX;
basicY = 1 - rotatedY;
break;
case 6:
basicX = 1 - rotatedY;
basicY = rotatedX;
break;
case 8:
basicX = rotatedY;
basicY = 1 - rotatedX;
break;
default:
basicX = rotatedX;
basicY = rotatedY;
break;
}
return [basicX, basicY];
}
/**
* Checks a value and returns it if it exists and is larger than 0.
* Fallbacks if it is null.
*
* @param {number} value - Value to check.
* @param {number} fallback - Value to fall back to.
* @returns {number} The value or its fallback value if it is not defined or negative.
*/
private _getValue(value: number, fallback: number): number {
return value != null && value > 0 ? value : fallback;
}
private _getCameraParameters(
value: number[],
cameraType: string): number[] {
if (isSpherical(cameraType)) { return []; }
if (!value || value.length === 0) { return [1, 0, 0]; }
const padding = 3 - value.length;
if (padding <= 0) { return value; }
return value
.concat(
new Array(padding)
.fill(0));
}
/**
* Creates the extrinsic camera matrix [ R | t ].
*
* @param {Array<number>} rotation - Rotation vector in angle axis representation.
* @param {Array<number>} translation - Translation vector.
* @returns {THREE.Matrix4} Extrisic camera matrix.
*/
private createWorldToCamera(
rotation: number[],
translation: number[]): THREE.Matrix4 {
const axis = new THREE.Vector3(rotation[0], rotation[1], rotation[2]);
const angle = axis.length();
if (angle > 0) {
axis.normalize();
}
const worldToCamera = new THREE.Matrix4();
worldToCamera.makeRotationAxis(axis, angle);
worldToCamera.setPosition(
new THREE.Vector3(
translation[0],
translation[1],
translation[2]));
return worldToCamera;
}
/**
* Calculates the scaled extrinsic camera matrix scale * [ R | t ].
*
* @param {THREE.Matrix4} worldToCamera - Extrisic camera matrix.
* @param {number} scale - Scale factor.
* @returns {THREE.Matrix4} Scaled extrisic camera matrix.
*/
private _createScaledWorldToCamera(
worldToCamera: THREE.Matrix4,
scale: number): THREE.Matrix4 {
const scaledWorldToCamera = worldToCamera.clone();
const elements = scaledWorldToCamera.elements;
elements[12] = scale * elements[12];
elements[13] = scale * elements[13];
elements[14] = scale * elements[14];
scaledWorldToCamera.scale(new THREE.Vector3(scale, scale, scale));
return scaledWorldToCamera;
}
private _createBasicWorldToCamera(rt: THREE.Matrix4, orientation: number): THREE.Matrix4 {
const axis: THREE.Vector3 = new THREE.Vector3(0, 0, 1);
let angle: number = 0;
switch (orientation) {
case 3:
angle = Math.PI;
break;
case 6:
angle = Math.PI / 2;
break;
case 8:
angle = 3 * Math.PI / 2;
break;
default:
break;
}
return new THREE.Matrix4()
.makeRotationAxis(axis, angle)
.multiply(rt);
}
private _getRadialPeak(k1: number, k2: number): number {
const a: number = 5 * k2;
const b: number = 3 * k1;
const c: number = 1;
const d: number = b ** 2 - 4 * a * c;
if (d < 0) {
return undefined;
}
const root1: number = (-b - Math.sqrt(d)) / 2 / a;
const root2: number = (-b + Math.sqrt(d)) / 2 / a;
const minRoot: number = Math.min(root1, root2);
const maxRoot: number = Math.max(root1, root2);
return minRoot > 0 ?
Math.sqrt(minRoot) :
maxRoot > 0 ?
Math.sqrt(maxRoot) :
undefined;
}
/**
* Calculate a transformation matrix from normalized coordinates for
* texture map coordinates.
*
* @returns {THREE.Matrix4} Normalized coordinates to texture map
* coordinates transformation matrix.
*/
private _normalizedToTextureMatrix(): THREE.Matrix4 {
const size: number = Math.max(this._width, this._height);
const scaleX: number = this._orientation < 5 ? this._textureScale[0] : this._textureScale[1];
const scaleY: number = this._orientation < 5 ? this._textureScale[1] : this._textureScale[0];
const w: number = size / this._width * scaleX;
const h: number = size / this._height * scaleY;
switch (this._orientation) {
case 1:
return new THREE.Matrix4().set(w, 0, 0, 0.5, 0, -h, 0, 0.5, 0, 0, 1, 0, 0, 0, 0, 1);
case 3:
return new THREE.Matrix4().set(-w, 0, 0, 0.5, 0, h, 0, 0.5, 0, 0, 1, 0, 0, 0, 0, 1);
case 6:
return new THREE.Matrix4().set(0, -h, 0, 0.5, -w, 0, 0, 0.5, 0, 0, 1, 0, 0, 0, 0, 1);
case 8:
return new THREE.Matrix4().set(0, h, 0, 0.5, w, 0, 0, 0.5, 0, 0, 1, 0, 0, 0, 0, 1);
default:
return new THREE.Matrix4().set(w, 0, 0, 0.5, 0, -h, 0, 0.5, 0, 0, 1, 0, 0, 0, 0, 1);
}
}
}