UNPKG

stereo-img

Version:

a web component to display stereographic pictures on web pages, with VR support

307 lines (261 loc) 11.6 kB
// Copyright 2021 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import exifr from './../../vendor/exifr/full.esm.js'; /** * fetch the image from URL * return left and right eye images from either left / right, right / left, top / bottom, bottom / top. * @Param {string} url - image url * @Param {Object} (options) - Parsing options: type: 'left-right' (default), 'right-left' or 'top-bottom', angle: '180' or '360' * */ async function parseStereo(url, options) { return parseStereoPair(url, null, options); } /** * fetch the image from separate url (primary) and secondaryURL * if secondaryURL is null, assume url contains a stereo images with both included * return left and right eye images from either left / right, right / left, top / bottom, bottom / top, url / secondaryURL. * @Param {string} url - image url left, or combined left and right * @Param {string} secondaryURL - image url right, or null * @Param {Object} (options) - Parsing options: type: 'left-right' (default), 'right-left' or 'top-bottom', angle: '180' or '360' * */ async function parseStereoPair(url, secondaryURL, options) { const image = await createImageFromURL(url); //image.crossOrigin = "Anonymous"; const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d', {willReadFrequently: true}); const width = image.width; const height = image.height; canvas.width = width; canvas.height = height; ctx.drawImage(image, 0, 0); var secondaryImage = null; var secondaryCanvas = null; var secondaryCtx = null; var secondaryWidth = 0; var secondaryHeight = 0; if (secondaryURL) { secondaryImage = await createImageFromURL(secondaryURL); //secondaryImage.crossOrigin = "Anonymous"; secondaryCanvas = document.createElement('canvas'); secondaryCtx = secondaryCanvas.getContext('2d', {willReadFrequently: true}); secondaryWidth = secondaryImage.width; secondaryHeight = secondaryImage.height; secondaryCanvas.width = secondaryWidth; secondaryCanvas.height = secondaryHeight; secondaryCtx.drawImage(secondaryImage, 0, 0); } function pixelIsBlack(pixel, threshold) { const blackThreshold = threshold || 10; return pixel[0] < blackThreshold && pixel[1] < blackThreshold && pixel[2] < blackThreshold; } const exif = await exifr.parse(image, { xmp: true, multiSegment: true }) // Images let type = options.type || 'left-right'; // Heuristics for Canon RF5.2mm F2.8 L DUAL FISHEYE // Unprocessed pictures are right-left. Pictures processed with EOS VR Utility software are left-right. if(exif?.Make === 'Canon' && exif?.LensModel === 'RF5.2mm F2.8 L DUAL FISHEYE') { if(exif?.Software?.includes('EOS VR Utility')) { type = 'left-right'; } else { type = 'right-left'; } } // check if fisheye projection let projection; if(options?.projection === 'fisheye') { projection = 'fisheye'; } else if(options?.projection === 'equirectangular') { ; } else { // Read pixels in each corner and middle to see if they are black. If black, assume fisheye image const topLeft = ctx.getImageData(0, 0, 1, 1).data; const topRight = ctx.getImageData(width - 1, 0, 1, 1).data; const bottomLeft = ctx.getImageData(0, height - 1, 1, 1).data; const bottomRight = ctx.getImageData(width - 1, height - 1, 1, 1).data; const middle = ctx.getImageData(width / 2, height / 2, 1, 1).data; if(pixelIsBlack(topLeft) && pixelIsBlack(topRight) && pixelIsBlack(bottomLeft) && pixelIsBlack(bottomRight) && pixelIsBlack(middle, 50)) { projection = 'fisheye'; console.log("Detected fisheye image"); } } let leftEye; let rightEye; let top = 0; let leftLeft = 0; let leftRight = width / 2; let bottom = height; let rightRight = width; let rightLeft = width / 2; // fisheye image might have black around image data, measure the actual top, left, bottom, right position of the fisheye circle // TODO: This assumes left-right or right-left. Make compatible with top-bottom and bottom-top. if(projection === 'fisheye') { // Heuristics for Canon RF5.2mm F2.8 L DUAL FISHEYE if(exif?.Make === 'Canon' && exif?.LensModel === 'RF5.2mm F2.8 L DUAL FISHEYE') { // Measured values on a reference image, see excmples/canon-eos-r5-dual-fisheye.jpg const referenceImageWidth = 8192; const referenceImageHeight = 5464; const referenceEyeDiameter = 3750; const referenceLeftEyeCenter = 1980; top = Math.round( (referenceImageHeight / 2 - referenceEyeDiameter / 2) * height / referenceImageHeight ); bottom = Math.round( (referenceImageHeight / 2 + referenceEyeDiameter / 2) * height / referenceImageHeight ); leftLeft = Math.round( (referenceLeftEyeCenter - referenceEyeDiameter / 2) * width / referenceImageWidth ); leftRight = Math.round( (referenceLeftEyeCenter + referenceEyeDiameter / 2) * width / referenceImageWidth ); rightLeft = Math.round( (referenceImageWidth - referenceLeftEyeCenter - referenceEyeDiameter / 2) * width / referenceImageWidth ); rightRight = Math.round( (referenceImageWidth - referenceLeftEyeCenter + referenceEyeDiameter / 2) * width / referenceImageWidth ); } else { // March from the edges and the middle to find hte actual edges of the fisheye image. // TODO: This is not a robust approach at all: // 1. centers are not necesserarlly at width / 4, top find the top, we should look for lines. // 2. the fisheye images might need cropping, so looking at the first non black pixel is not enough. // 3. there might be lense flare that makes black not black, and the eye pixels might be black . // top for(let y = 0; y < height / 2; y++) { const pixel = ctx.getImageData(width / 4, y, 1, 1).data; if(!pixelIsBlack(pixel)) { top = y; break; } } // bottom for(let y = height - 1; y > height / 2; y--) { const pixel = ctx.getImageData(width / 4, y, 1, 1).data; if(!pixelIsBlack(pixel)) { bottom = y; break; } } // left of the left fisheye for(let x = 0; x < width / 4; x++) { const pixel = ctx.getImageData(x, height / 2, 1, 1).data; if(!pixelIsBlack(pixel)) { leftLeft = x; break; } } // right of the left fisheye for(let x = width / 2 - 1; x > width / 4; x--) { const pixel = ctx.getImageData(x, height / 2, 1, 1).data; if(!pixelIsBlack(pixel)) { leftRight = x; break; } } // right of the right fisheye for(let x = width - 1; x > 3 * width / 4; x--) { const pixel = ctx.getImageData(x, height / 2, 1, 1).data; if(!pixelIsBlack(pixel)) { rightRight = x; break; } } // left of the right fisheye for(let x = width / 2 - 1; x > width / 4; x++) { const pixel = ctx.getImageData(x, height / 2, 1, 1).data; if(!pixelIsBlack(pixel)) { rightLeft = x; break; } } } console.log({top, bottom, leftLeft, leftRight, rightLeft, rightRight}); } switch(type) { case 'left-right': leftEye = ctx.getImageData(leftLeft, top, leftRight - leftLeft, bottom - top); rightEye = ctx.getImageData(rightLeft, top, rightRight - rightLeft, bottom - top); break; case 'right-left': leftEye = ctx.getImageData(rightLeft, top, rightRight - rightLeft, bottom - top); rightEye = ctx.getImageData(leftLeft, top, leftRight - leftLeft, bottom - top); break; case 'top-bottom': leftEye = ctx.getImageData(0, 0, width, height / 2); rightEye = ctx.getImageData(0, height / 2, width, height / 2); break; case 'bottom-top': leftEye = ctx.getImageData(0, height / 2, width, height / 2); rightEye = ctx.getImageData(0, 0, width, height / 2); break; case 'pair': leftEye = ctx.getImageData(0, 0, width, height); rightEye = secondaryCtx.getImageData(0, 0, secondaryWidth, secondaryHeight); break; } let angleOfViewFocalLengthIn35mmFormat = function(focalLengthIn35mmFormat) { // https://en.wikipedia.org/wiki/Angle_of_view#Common_lens_angles_of_view // https://en.wikipedia.org/wiki/35_mm_equivalent_focal_length // angle of view on the diagonal (35mm is 24 mm (vertically) × 36 mm (horizontal), giving a diagonal of about 43.3 mm) const diagonalAngle = 2 * Math.atan(43.3 / (2 * focalLengthIn35mmFormat)); // Pi / 4 for a square. const halfAngle = Math.atan(height / (width / 2)); const horizontalAngle = diagonalAngle * Math.cos(halfAngle); const verticalAngle = diagonalAngle * Math.sin(halfAngle); return {diagonalAngle, horizontalAngle, verticalAngle}; } // Angles let phiLength; let thetaLength; if(options?.angle === "180" || options?.angle === 180) { phiLength = Math.PI; thetaLength = Math.PI; } else if(options?.angle === "360" || options?.angle === 360) { phiLength = Math.PI * 2; thetaLength = Math.PI; } else if(projection === 'fisheye') { // If fisheye, assume 180. phiLength = Math.PI; thetaLength = Math.PI; } else if(exif?.FocalLengthIn35mmFormat) { const angle = angleOfViewFocalLengthIn35mmFormat(exif.FocalLengthIn35mmFormat); phiLength = angle.horizontalAngle; thetaLength = angle.verticalAngle; } else if(exif?.Make === 'GoPro' || url.startsWith('gopro') || url.startsWith('GOPR') ) { // GoPro (https://gopro.com/help/articles/question_answer/hero7-field-of-view-fov-information?sf96748270=1) phiLength = 2.1397737; // 122.6º thetaLength = 1.647591; // 94.4º } else if(exif?.Make === 'Canon' && exif?.LensModel === 'RF5.2mm F2.8 L DUAL FISHEYE') { // TODO: This lense has a 190º angle of view, but the viewer doesn't support any other angle than 180 for fisheye. phiLength = Math.PI; thetaLength = Math.PI; } else if(exif?.Model === 'insta360 evo') { phiLength = Math.PI; thetaLength = Math.PI; } else if(options?.angle > 0 && options?.angle <= 360) { const angle = options?.angle * Math.PI/180.0; phiLength = angle; thetaLength = angle * height/width; } else { const assumeFocalLengthIn35mmFormat = 27; const angle = angleOfViewFocalLengthIn35mmFormat(assumeFocalLengthIn35mmFormat); phiLength = angle.horizontalAngle; thetaLength = angle.verticalAngle; } const thetaStart = Math.PI / 2 - thetaLength / 2; return {leftEye, rightEye, phiLength, thetaStart, thetaLength, projection}; } async function createImageFromURL(url) { const image = new Image(); image.src = url; return new Promise((resolve, reject) => { image.onload = () => { URL.revokeObjectURL(url); resolve(image); }; image.onerror = reject; }); } export {parseStereo, parseStereoPair}