Spherical harmonic transform (SHT) previews are display instead of a solid background color before the base multires tiles are loaded. If a thumbnail is provided, it will be displayed instead of the SHT preview. If the thumbnail is provided as a Base64-encoded string, the SHT preview may be briefly displayed, since image loading is asynchronous, while the SHT preview is loaded synchronously.pull/1015/head
@@ -492,6 +492,23 @@ This specifies the size in pixels of the full resolution cube faces the image | |||||
tiles were created from. | tiles were created from. | ||||
#### `shtHash` (string) | |||||
Specifies the spherical-harmonic-transform-based preview hash. This is rendered | |||||
instead of the background color before the base set of cube faces are loaded. | |||||
#### `equirectangularThumbnail` (string or HTMLImageElement or ImageData or ImageBitmap) | |||||
Specifies a equirectangular preview thumbnail to be rendered instead of the | |||||
background color or SHT hash before the base set of cube faces are loaded. This | |||||
image can either be specified as a Base64-encoded string or as an object that | |||||
can be directly uploaded to a WebGL texture, e.g., `ImageData`, `ImageBitmap`, | |||||
`HTMLImageElement`, `HTMLCanvasElement` objects. If a Base64-encoded string is | |||||
used, the image size should be kept small, since it needs to be loaded with the | |||||
configuration parameters. | |||||
## Dynamic content specific options | ## Dynamic content specific options | ||||
@@ -0,0 +1,65 @@ | |||||
# Spherical harmonic transform hash | |||||
This document specifies a spherical harmonic transform (SHT) hash, which is | |||||
intended to be a compact method of encoding a spherical panorama preview. It is | |||||
based on the [BlurHash specification](https://github.com/woltapp/blurhash/blob/master/Algorithm.md) | |||||
for DCT-based 2D image previews. | |||||
There are three steps for creating a SHT hash: | |||||
1. Calculate real spherical harmonic transform coefficients | |||||
2. Encode with compact binary encoding | |||||
3. Encode binary data as Base-83-encoded string | |||||
Spherical harmonics form an orthogonal basis for representing a function on the | |||||
sphere. Combined with coefficients for each harmonic, they can be used to | |||||
represent a frequency-space approximation of such a function, without boundary | |||||
effects. There are multiple normalization conventions for spherical harmonics; | |||||
the $4\pi$ convention is used here. Since JavaScript does not natively support | |||||
complex numbers, real harmonics with separate real sine and cosine coefficients | |||||
are used. | |||||
Spherical harmonics, $Y_{\ell m}$, are defined for $\ell \in \mathbb{Z}^+$, | |||||
with $|m| \leq \ell$. For each $Y_{\ell m}$, there is a corresponding | |||||
$f_{\ell m}$ coefficient. When these coefficients are represented in a pair of | |||||
matrices (one for the sine coefficient and one for the cosine coefficients) | |||||
with rows indexed by $\ell$ and columns indexed by $m$, the upper triangle of | |||||
the matrix is zero. Additionally, the $\ell = m = 1$ coefficient in the sine | |||||
matrix is always 1 in the $4\pi$ normalization convention, which is why it is | |||||
used here. Spherical harmonic coefficients are calculated separately for each | |||||
color channel. Once calculated, the sine and cosine coefficients are stored in | |||||
1D arrays using row-first ordering with the upper triangle of the matrices | |||||
excluded. The coefficient arrays also excludes the first row and column of | |||||
the coefficient matrices since their contents are always zero, except for the | |||||
$\ell = m = 1$ sine coefficient, which is always 1 (as previously mentioned). | |||||
The 1D cosine coefficient array is appended to the 1D sine coefficient array, | |||||
for each of the color channels. | |||||
The maximum coefficient magnitude is then found across the color channels, and | |||||
this value is used to normalize the coefficients in the range $[-1, 1]$. The | |||||
normalized coefficients are then multiplied by 9 and converted to integers, | |||||
thereby quantizing the coefficients as integer values. These signed integers in | |||||
the range $[-9, 9]$ are then converted to unsigned integers in the range | |||||
$[0, 18]$ by adding 9. For each coefficient, the color channel values are | |||||
packed into a single number in the range $[0, 6859]$ using | |||||
$R \cdot 19^2 + G \cdot 19 + B$. This number is then Base-83-encoded into a | |||||
pair of characters. Color is encoded and decoded using gamma-compressed sRGB | |||||
values, for simplicity. | |||||
The final SHT hash string is constructed by combining the Base-83-encoded | |||||
coefficients with a prefix. The first character in the prefix contains the max | |||||
$\ell$ value for the coefficients, encoded as Base 83. For Pannellum, this is | |||||
currently fixed at $\ell = 5$. The next character contains the maximum | |||||
coefficient value, which was used in the normalization. The value is divided by | |||||
255 to normalize it to the range $[0, 1]$. This value is then multiplied by 82 | |||||
and quantized as an integer, before being Base-83 encoded. For $\ell = 5$ this | |||||
string is 74 characters in length. | |||||
## Base 83 | |||||
A custom Base-83 encoding is used. Values are encoded individually, using one | |||||
or two digits, and concatenated together. Multiple-digit values are encoded in | |||||
big-endian order, with the most-significant digit first. | |||||
The character set used is `0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~`. |
@@ -35,7 +35,7 @@ Configuration parameters are documented in the `doc/json-config-parameters.md` f | |||||
For final deployment, it is recommended that one use a minified copy of Pannellum instead of using the source files in `src` directly. The easiest method is to download the most recent [release](https://github.com/mpetroff/pannellum/releases) and use the pre-built copy of either `pannellum.htm` or `pannellum.js` & `pannellum.css`. If you wish to make changes to Pannellum or use the latest development copy of the code, follow the instructions in the _Building_ section below to create `build/pannellum.htm`, `build/pannellum.js`, and `build/pannellum.css`. | For final deployment, it is recommended that one use a minified copy of Pannellum instead of using the source files in `src` directly. The easiest method is to download the most recent [release](https://github.com/mpetroff/pannellum/releases) and use the pre-built copy of either `pannellum.htm` or `pannellum.js` & `pannellum.css`. If you wish to make changes to Pannellum or use the latest development copy of the code, follow the instructions in the _Building_ section below to create `build/pannellum.htm`, `build/pannellum.js`, and `build/pannellum.css`. | ||||
### Using `generate.py` to create multires panoramas | ### Using `generate.py` to create multires panoramas | ||||
To be able to create multiresolution panoramas, you need to have the `nona` program installed, which is available as part of [Hugin](http://hugin.sourceforge.net/), as well as Python with the [Pillow](https://pillow.readthedocs.org/) package. Then, run | |||||
To be able to create multiresolution panoramas, you need to have the `nona` program installed, which is available as part of [Hugin](http://hugin.sourceforge.net/), as well as Python 3 with the [Pillow](https://pillow.readthedocs.org/) and [NumPy](https://numpy.org/) packages. The [pyshtools](https://shtools.github.io/SHTOOLS/) Python package is also recommended. Then, run | |||||
``` | ``` | ||||
python3 generate.py pano_image.jpg | python3 generate.py pano_image.jpg | ||||
@@ -36,6 +36,7 @@ function Renderer(container) { | |||||
container.appendChild(canvas); | container.appendChild(canvas); | ||||
var program, gl, vs, fs; | var program, gl, vs, fs; | ||||
var previewProgram, previewVs, previewFs; | |||||
var fallbackImgSize; | var fallbackImgSize; | ||||
var world; | var world; | ||||
var vtmps; | var vtmps; | ||||
@@ -99,6 +100,18 @@ function Renderer(container) { | |||||
gl.deleteProgram(program); | gl.deleteProgram(program); | ||||
program = undefined; | program = undefined; | ||||
} | } | ||||
if (previewProgram) { | |||||
if (previewVs) { | |||||
gl.detachShader(previewProgram, previewVs); | |||||
gl.deleteShader(previewVs); | |||||
} | |||||
if (previewFs) { | |||||
gl.detachShader(previewProgram, previewFs); | |||||
gl.deleteShader(previewFs); | |||||
} | |||||
gl.deleteProgram(previewProgram); | |||||
previewProgram = undefined; | |||||
} | |||||
pose = undefined; | pose = undefined; | ||||
var s; | var s; | ||||
@@ -470,7 +483,10 @@ function Renderer(container) { | |||||
} | } | ||||
// Set parameters for rendering any size | // Set parameters for rendering any size | ||||
gl.texParameteri(glBindType, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); | |||||
if (imageType != "cubemap" && image.width <= maxWidth && haov == 2 * Math.PI) | |||||
gl.texParameteri(glBindType, gl.TEXTURE_WRAP_S, gl.REPEAT); | |||||
else | |||||
gl.texParameteri(glBindType, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); | |||||
gl.texParameteri(glBindType, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); | gl.texParameteri(glBindType, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); | ||||
gl.texParameteri(glBindType, gl.TEXTURE_MIN_FILTER, gl.LINEAR); | gl.texParameteri(glBindType, gl.TEXTURE_MIN_FILTER, gl.LINEAR); | ||||
gl.texParameteri(glBindType, gl.TEXTURE_MAG_FILTER, gl.LINEAR); | gl.texParameteri(glBindType, gl.TEXTURE_MAG_FILTER, gl.LINEAR); | ||||
@@ -512,6 +528,124 @@ function Renderer(container) { | |||||
program.nodeCache = []; | program.nodeCache = []; | ||||
program.nodeCacheTimestamp = 0; | program.nodeCacheTimestamp = 0; | ||||
program.textureLoads = []; | program.textureLoads = []; | ||||
if (image.shtHash || image.equirectangularThumbnail) { | |||||
// Create vertex shader | |||||
previewVs = gl.createShader(gl.VERTEX_SHADER); | |||||
gl.shaderSource(previewVs, v); | |||||
gl.compileShader(previewVs); | |||||
// Create fragment shader | |||||
previewFs = gl.createShader(gl.FRAGMENT_SHADER); | |||||
gl.shaderSource(previewFs, fragEquirectangular); | |||||
gl.compileShader(previewFs); | |||||
// Link WebGL program | |||||
previewProgram = gl.createProgram(); | |||||
gl.attachShader(previewProgram, previewVs); | |||||
gl.attachShader(previewProgram, previewFs); | |||||
gl.linkProgram(previewProgram); | |||||
// Log errors | |||||
if (!gl.getShaderParameter(previewVs, gl.COMPILE_STATUS)) | |||||
console.log(gl.getShaderInfoLog(previewVs)); | |||||
if (!gl.getShaderParameter(previewFs, gl.COMPILE_STATUS)) | |||||
console.log(gl.getShaderInfoLog(previewFs)); | |||||
if (!gl.getProgramParameter(previewProgram, gl.LINK_STATUS)) | |||||
console.log(gl.getProgramInfoLog(previewProgram)); | |||||
// Use WebGL program | |||||
gl.useProgram(previewProgram); | |||||
// Look up texture coordinates location | |||||
previewProgram.texCoordLocation = gl.getAttribLocation(previewProgram, 'a_texCoord'); | |||||
gl.enableVertexAttribArray(previewProgram.texCoordLocation); | |||||
// Provide texture coordinates for rectangle | |||||
if (!texCoordBuffer) | |||||
texCoordBuffer = gl.createBuffer(); | |||||
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer); | |||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,1,1,1,1,-1,-1,1,1,-1,-1,-1]), gl.STATIC_DRAW); | |||||
gl.vertexAttribPointer(previewProgram.texCoordLocation, 2, gl.FLOAT, false, 0, 0); | |||||
// Pass aspect ratio | |||||
previewProgram.aspectRatio = gl.getUniformLocation(previewProgram, 'u_aspectRatio'); | |||||
gl.uniform1f(previewProgram.aspectRatio, gl.drawingBufferWidth / gl.drawingBufferHeight); | |||||
// Locate psi, theta, focal length, horizontal extent, vertical extent, and vertical offset | |||||
previewProgram.psi = gl.getUniformLocation(previewProgram, 'u_psi'); | |||||
previewProgram.theta = gl.getUniformLocation(previewProgram, 'u_theta'); | |||||
previewProgram.f = gl.getUniformLocation(previewProgram, 'u_f'); | |||||
previewProgram.h = gl.getUniformLocation(previewProgram, 'u_h'); | |||||
previewProgram.v = gl.getUniformLocation(previewProgram, 'u_v'); | |||||
previewProgram.vo = gl.getUniformLocation(previewProgram, 'u_vo'); | |||||
previewProgram.rot = gl.getUniformLocation(previewProgram, 'u_rot'); | |||||
// Pass horizontal extent | |||||
gl.uniform1f(previewProgram.h, 1.0); | |||||
// Create texture | |||||
previewProgram.texture = gl.createTexture(); | |||||
gl.bindTexture(glBindType, previewProgram.texture); | |||||
// Upload preview image to the texture | |||||
var previewImage, vext, voff; | |||||
var uploadPreview = function() { | |||||
gl.useProgram(previewProgram); | |||||
gl.uniform1i(gl.getUniformLocation(previewProgram, 'u_splitImage'), 0); | |||||
gl.texImage2D(glBindType, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, previewImage); | |||||
// Set parameters for rendering any size | |||||
gl.texParameteri(glBindType, gl.TEXTURE_WRAP_S, gl.REPEAT); | |||||
gl.texParameteri(glBindType, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); | |||||
gl.texParameteri(glBindType, gl.TEXTURE_MIN_FILTER, gl.LINEAR); | |||||
gl.texParameteri(glBindType, gl.TEXTURE_MAG_FILTER, gl.LINEAR); | |||||
// Pass vertical extent and vertical offset | |||||
gl.uniform1f(previewProgram.v, vext); | |||||
gl.uniform1f(previewProgram.vo, voff); | |||||
gl.useProgram(program); | |||||
}; | |||||
if (image.shtHash) { | |||||
previewImage = shtDecodeImage(image.shtHash); | |||||
// Vertical extent & offset are chosen to set the top and bottom | |||||
// pixels in the preview image to be exactly at the zenith and | |||||
// nadir, respectively, which matches the pre-calculated Ylm | |||||
vext = (2 + 1 / 31) / 2; | |||||
voff = 1 - (2 + 1 / 31) / 2; | |||||
uploadPreview(); | |||||
} | |||||
if (image.equirectangularThumbnail) { | |||||
if (typeof image.equirectangularThumbnail === 'string') { | |||||
if (image.equirectangularThumbnail.slice(0, 5) == 'data:') { | |||||
// Data URI | |||||
previewImage = new Image(); | |||||
previewImage.onload = function() { | |||||
vext = 1; | |||||
voff = 0; | |||||
uploadPreview(); | |||||
}; | |||||
previewImage.src = image.equirectangularThumbnail; | |||||
} else { | |||||
console.log('Error: thumbnail string is not a data URI!'); | |||||
throw {type: 'config error'}; | |||||
} | |||||
} else { | |||||
// ImageData / ImageBitmap / HTMLImageElement / HTMLCanvasElement | |||||
previewImage = image.equirectangularThumbnail; | |||||
vext = 1; | |||||
voff = 0; | |||||
uploadPreview(); | |||||
} | |||||
} | |||||
// Reactivate main program | |||||
gl.bindBuffer(gl.ARRAY_BUFFER, cubeVertBuf); | |||||
gl.vertexAttribPointer(program.vertPosLocation, 3, gl.FLOAT, false, 0, 0); | |||||
gl.useProgram(program); | |||||
} | |||||
} | } | ||||
// Check if there was an error | // Check if there was an error | ||||
@@ -562,6 +696,10 @@ function Renderer(container) { | |||||
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight); | gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight); | ||||
if (imageType != 'multires') { | if (imageType != 'multires') { | ||||
gl.uniform1f(program.aspectRatio, canvas.clientWidth / canvas.clientHeight); | gl.uniform1f(program.aspectRatio, canvas.clientWidth / canvas.clientHeight); | ||||
} else if (image.shtHash) { | |||||
gl.useProgram(previewProgram); | |||||
gl.uniform1f(previewProgram.aspectRatio, canvas.clientWidth / canvas.clientHeight); | |||||
gl.useProgram(program); | |||||
} | } | ||||
} | } | ||||
}; | }; | ||||
@@ -688,6 +826,41 @@ function Renderer(container) { | |||||
gl.drawArrays(gl.TRIANGLES, 0, 6); | gl.drawArrays(gl.TRIANGLES, 0, 6); | ||||
} else { | } else { | ||||
// Draw SHT hash preview, if needed | |||||
var drawPreview = typeof image.shtHash !== 'undefined' | |||||
if (drawPreview && program.currentNodes.length >= 6) { | |||||
drawPreview = false; | |||||
for (var i = 0; i < 6; i++) { | |||||
if (!program.currentNodes[i].textureLoaded) { | |||||
drawPreview = true; | |||||
break; | |||||
} | |||||
} | |||||
} | |||||
if (drawPreview) { | |||||
gl.useProgram(previewProgram); | |||||
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer); | |||||
gl.vertexAttribPointer(previewProgram.texCoordLocation, 2, gl.FLOAT, false, 0, 0); | |||||
gl.bindTexture(gl.TEXTURE_2D, previewProgram.texture); | |||||
// Calculate focal length from vertical field of view | |||||
var vfov = 2 * Math.atan(Math.tan(hfov * 0.5) / (gl.drawingBufferWidth / gl.drawingBufferHeight)); | |||||
focal = 1 / Math.tan(vfov * 0.5); | |||||
// Pass psi, theta, roll, and focal length | |||||
gl.uniform1f(previewProgram.psi, yaw); | |||||
gl.uniform1f(previewProgram.theta, pitch); | |||||
gl.uniform1f(previewProgram.rot, roll); | |||||
gl.uniform1f(previewProgram.f, focal); | |||||
// Draw using current buffer | |||||
gl.drawArrays(gl.TRIANGLES, 0, 6); | |||||
gl.bindBuffer(gl.ARRAY_BUFFER, cubeVertBuf); | |||||
gl.vertexAttribPointer(program.vertPosLocation, 3, gl.FLOAT, false, 0, 0); | |||||
gl.useProgram(program); | |||||
} | |||||
// Create perspective matrix | // Create perspective matrix | ||||
var perspMatrix = makePersp(hfov, gl.drawingBufferWidth / gl.drawingBufferHeight, 0.1, 100.0); | var perspMatrix = makePersp(hfov, gl.drawingBufferWidth / gl.drawingBufferHeight, 0.1, 100.0); | ||||
@@ -756,7 +929,7 @@ function Renderer(container) { | |||||
program.textureLoads.shift()(); | program.textureLoads.shift()(); | ||||
// Draw tiles | // Draw tiles | ||||
multiresDraw(); | |||||
multiresDraw(!image.shtHash); | |||||
} | } | ||||
if (params.returnImage !== undefined) { | if (params.returnImage !== undefined) { | ||||
@@ -837,13 +1010,15 @@ function Renderer(container) { | |||||
/** | /** | ||||
* Draws multires nodes. | * Draws multires nodes. | ||||
* @param {bool} clear - Whether or not to clear canvas. | |||||
* @private | * @private | ||||
*/ | */ | ||||
function multiresDraw() { | |||||
function multiresDraw(clear) { | |||||
if (!program.drawInProgress) { | if (!program.drawInProgress) { | ||||
program.drawInProgress = true; | program.drawInProgress = true; | ||||
// Clear canvas | // Clear canvas | ||||
gl.clear(gl.COLOR_BUFFER_BIT); | |||||
if (clear) | |||||
gl.clear(gl.COLOR_BUFFER_BIT); | |||||
// Determine tiles that need to be drawn | // Determine tiles that need to be drawn | ||||
var node_paths = {}; | var node_paths = {}; | ||||
for (var i = 0; i < program.currentNodes.length; i++) | for (var i = 0; i < program.currentNodes.length; i++) | ||||
@@ -1408,6 +1583,153 @@ function Renderer(container) { | |||||
canvas.width = Math.round(canvas.width / 2); | canvas.width = Math.round(canvas.width / 2); | ||||
canvas.height = Math.round(canvas.height / 2); | canvas.height = Math.round(canvas.height / 2); | ||||
} | } | ||||
// Data for rendering SHT hashes | |||||
var shtB83chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~', | |||||
shtYlmStr = 'Bf[ff4fff|ffff0fffffBo@Ri5xag{Jmdf2+WiefCs@Ll7+Vi]Btag6' + | |||||
'[NmdgCv=Ho9;Qk;7zWiF_GsahDy:ErE?Mn$5+SkS_AyWiD#-CuJ[Iqp6;Nnx?7*SlE$' + | |||||
'*BxR@FtPA?Jq+%7:NnF*zAzn?CwIG@Ft-Y9?IrG+vA%w:AzGR?Cx*IF@EuI,nA+$*9%' + | |||||
'Gu:A#xCR?ByJ-VB-*wA+J**9*ZBv:9%L.QD.*aB.O.v9-MF+$8,O:MG:*OD;a:UB:IO' + | |||||
':n9:Q:KJ;#IG=u-KE=Hs:MC?T:IO=wEL?#%FJ@K**FI@Y;HV=pDU?*sCS@S.uCR[m;H' + | |||||
'p=VDq?*SCs@s.QCt[r:Iw=OEz?#IF$@#*HF%@u:K$;KI+=uEK-=*sCM:?w:M+:HO.;a' + | |||||
'CU;:%OCn?:z.Q..Ha;.ODv?-yFG$@,$-V;-Hw=+JH*?*lBP:?%%,n=+J*?%GQ:=#NCt' + | |||||
'?;y++v=%O:=zGt?:xHI,@-u,*z=zX?:wI+@,tEY??%r-$*;xt@,tP=?$qG%[:xn.#-:' + | |||||
'u$[%qp];xnN?[*sl.y:-r-?yn$^+sks_=yoi:v=*o?;uk;[zoi,_+skh:s@zl[+pi];' + | |||||
'tkg][xmhg;o@ti^xkg{$mhf|+oigf;f[ff_fff|ffff~fffff', | |||||
shtMaxYlm = 3.317, | |||||
shtYlm = []; | |||||
/** | |||||
* Decodes an integer-encoded float. | |||||
* @private | |||||
* @param {number} i - Integer-encoded float. | |||||
* @param {number} maxVal - Maximum value of decoded float. | |||||
* @returns {number} Decoded float. | |||||
*/ | |||||
function shtDecodeFloat(i, maxVal) { | |||||
return Math.pow(((Math.abs(i) - maxVal) / maxVal), 2) * (i - maxVal > 0 ? 1 : -1); | |||||
} | |||||
/** | |||||
* Decodes encoded spherical harmonic transform coefficients. | |||||
* @private | |||||
* @param {number} val - Encoded coefficient. | |||||
* @param {number} maxVal - Maximum value of coefficients. | |||||
* @returns {number[]} Decoded coefficients; one per color channel [r, g, b]. | |||||
*/ | |||||
function shtDecodeCoeff(val, maxVal) { | |||||
var quantR = Math.floor(val / (19 * 19)), | |||||
quantG = Math.floor(val / 19) % 19, | |||||
quantB = val % 19; | |||||
var r = shtDecodeFloat(quantR, 9) * maxVal, | |||||
g = shtDecodeFloat(quantG, 9) * maxVal, | |||||
b = shtDecodeFloat(quantB, 9) * maxVal; | |||||
return [r, g, b]; | |||||
} | |||||
/** | |||||
* Decodes base83-encoded string to integers. | |||||
* @private | |||||
* @param {string} b83str - Encoded string. | |||||
* @param {number} length - Number of characters per integer. | |||||
* @returns {number[]} Decoded integers. | |||||
*/ | |||||
function shtB83decode(b83str, length) { | |||||
var cnt = Math.floor(b83str.length / length), | |||||
vals = []; | |||||
for (var i = 0; i < cnt; i++) { | |||||
var val = 0; | |||||
for (var j = 0; j < length; j++) { | |||||
val = val * 83 + shtB83chars.indexOf(b83str[i * length + j]); | |||||
} | |||||
vals.push(val); | |||||
} | |||||
return vals; | |||||
} | |||||
/** | |||||
* Renders pixel from spherical harmonic transform coefficients. | |||||
* @private | |||||
* @param {number[]} flm - Real spherical harmonic transform coefficients. | |||||
* @param {number[]} Ylm - 4pi-normalized spherical harmonics evaluated for ell, m, and lat. | |||||
* @param {number} lon - Longitude (radians). | |||||
* @returns {number} Pixel value. | |||||
*/ | |||||
function shtFlm2pixel(flm, Ylm, lon) { | |||||
var lmax = Math.floor(Math.sqrt(flm.length)) - 1 | |||||
// Precalculate sine and cosine coefficients | |||||
var cosm = Array(lmax + 1), | |||||
sinm = Array(lmax + 1); | |||||
sinm[0] = 0; | |||||
cosm[0] = 1; | |||||
sinm[1] = Math.sin(lon); | |||||
cosm[1] = Math.cos(lon); | |||||
for (var m = 2; m <= lmax; m++) { | |||||
sinm[m] = 2 * sinm[m - 1] * cosm[1] - sinm[m - 2]; | |||||
cosm[m] = 2 * cosm[m - 1] * cosm[1] - cosm[m - 2]; | |||||
} | |||||
// Calculate value at pixel | |||||
var expand = 0, | |||||
cosidx = 0; | |||||
for (var i = 1; i <= lmax + 1; i++) | |||||
cosidx += i; | |||||
for (var l = lmax; l >= 0; l--) { | |||||
var idx = Math.floor((l + 1) * l / 2); | |||||
// First coefficient is 1 when using 4pi normalization | |||||
expand += idx != 0 ? flm[idx] * Ylm[idx - 1] : flm[idx]; | |||||
for (var m = 1; m <= l; m++) | |||||
expand += (flm[++idx] * cosm[m] + flm[idx + cosidx - l - 1] * sinm[m]) * Ylm[idx - 1]; | |||||
} | |||||
return Math.round(expand); | |||||
} | |||||
/** | |||||
* Renders image from spherical harmonic transform (SHT) hash. | |||||
* @private | |||||
* @param {string} shtHash - SHT hash. | |||||
* @returns {ImageData} Rendered image. | |||||
*/ | |||||
function shtDecodeImage(shtHash) { | |||||
if (shtYlm.length < 1) { | |||||
// Decode Ylm if they're not already decoded | |||||
var ylmLen = shtYlmStr.length / 32; | |||||
for (var i = 0; i < 32; i++) { | |||||
shtYlm.push([]); | |||||
for (var j = 0; j < ylmLen; j++) | |||||
shtYlm[i].push(shtDecodeFloat(shtB83decode(shtYlmStr[i * ylmLen + j], 1), 41) * shtMaxYlm); | |||||
} | |||||
} | |||||
// Decode SHT hash | |||||
var lmax = shtB83decode(shtHash[0], 1)[0], | |||||
maxVal = (shtDecodeFloat(shtB83decode(shtHash[1], 1), 41) + 1) * 255 / 2, | |||||
vals = shtB83decode(shtHash.slice(2), 2), | |||||
rVals = [], | |||||
gVals = [], | |||||
bVals = []; | |||||
for (var i = 0; i < vals.length; i++) { | |||||
var v = shtDecodeCoeff(vals[i], maxVal); | |||||
rVals.push(v[0]); | |||||
gVals.push(v[1]); | |||||
bVals.push(v[2]); | |||||
} | |||||
// Render image | |||||
var lonStep = 0.03125 * Math.PI; | |||||
var img = []; | |||||
for (var i = 31; i >= 0; i--) { | |||||
for (var j = 0; j < 64; j++) { | |||||
img.push(shtFlm2pixel(rVals, shtYlm[i], (j + 0.5) * lonStep)); | |||||
img.push(shtFlm2pixel(gVals, shtYlm[i], (j + 0.5) * lonStep)); | |||||
img.push(shtFlm2pixel(bVals, shtYlm[i], (j + 0.5) * lonStep)); | |||||
img.push(255); | |||||
} | |||||
} | |||||
return new ImageData(new Uint8ClampedArray(img), 64, 32); | |||||
} | |||||
} | } | ||||
// Vertex shader for equirectangular and cube | // Vertex shader for equirectangular and cube | ||||
@@ -5,8 +5,9 @@ FROM ubuntu:20.04 | |||||
ENV DEBIAN_FRONTEND noninteractive | ENV DEBIAN_FRONTEND noninteractive | ||||
RUN apt-get update && apt-get install -y --no-install-recommends \ | RUN apt-get update && apt-get install -y --no-install-recommends \ | ||||
python3 python3-dev python3-pil hugin-tools \ | |||||
python3 python3-dev python3-numpy python3-pip python3-pil hugin-tools \ | |||||
&& rm -rf /var/lib/apt/lists/* | && rm -rf /var/lib/apt/lists/* | ||||
RUN pip3 install pyshtools | |||||
ADD generate.py /generate.py | ADD generate.py /generate.py | ||||
ENTRYPOINT ["python3", "/generate.py"] | ENTRYPOINT ["python3", "/generate.py"] |
@@ -1,7 +1,8 @@ | |||||
#!/usr/bin/env python3 | #!/usr/bin/env python3 | ||||
# Requires Python 3.2+ (or Python 2.7), the Python Pillow package, | |||||
# and nona (from Hugin) | |||||
# Requires Python 3.2+, the Python Pillow and NumPy packages, and | |||||
# nona (from Hugin). The Python pyshtools package is also needed for creating | |||||
# spherical-harmonic-transform previews (which are recommended). | |||||
# generate.py - A multires tile set generator for Pannellum | # generate.py - A multires tile set generator for Pannellum | ||||
# Extensions to cylindrical input and partial panoramas by David von Oheimb | # Extensions to cylindrical input and partial panoramas by David von Oheimb | ||||
@@ -35,6 +36,9 @@ import math | |||||
import ast | import ast | ||||
from distutils.spawn import find_executable | from distutils.spawn import find_executable | ||||
import subprocess | import subprocess | ||||
import base64 | |||||
import io | |||||
import numpy as np | |||||
# Allow large images (this could lead to a denial of service attack if you're | # Allow large images (this could lead to a denial of service attack if you're | ||||
# running this script on user-submitted images.) | # running this script on user-submitted images.) | ||||
@@ -47,6 +51,57 @@ except KeyError: | |||||
# Handle case of PATH not being set | # Handle case of PATH not being set | ||||
nona = None | nona = None | ||||
genPreview = False | |||||
try: | |||||
import pyshtools as pysh | |||||
genPreview = True | |||||
except: | |||||
sys.stderr.write("Unable to import pyshtools. Not generating SHT preview.\n") | |||||
def img2shtHash(img, lmax=5): | |||||
''' | |||||
Create spherical harmonic transform (SHT) hash preview. | |||||
''' | |||||
def encodeFloat(f, maxVal): | |||||
return np.maximum(0, np.minimum(2 * maxVal, np.round(np.sign(f) * np.sqrt(np.abs(f)) * maxVal + maxVal))).astype(int) | |||||
def encodeCoeff(r, g, b, maxVal): | |||||
quantR = encodeFloat(r / maxVal, 9) | |||||
quantG = encodeFloat(g / maxVal, 9) | |||||
quantB = encodeFloat(b / maxVal, 9) | |||||
return quantR * 19 ** 2 + quantG * 19 + quantB | |||||
b83chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" | |||||
def b83encode(vals, length): | |||||
result = "" | |||||
for val in vals: | |||||
for i in range(1, length + 1): | |||||
result += b83chars[int(val // (83 ** (length - i))) % 83] | |||||
return result | |||||
# Calculate SHT coefficients | |||||
r = pysh.expand.SHExpandDH(img[..., 0], sampling=2, lmax_calc=lmax) | |||||
g = pysh.expand.SHExpandDH(img[..., 1], sampling=2, lmax_calc=lmax) | |||||
b = pysh.expand.SHExpandDH(img[..., 2], sampling=2, lmax_calc=lmax) | |||||
# Remove values above diagonal for both sine and cosine components | |||||
# Also remove first row and column for sine component | |||||
# These values are always zero | |||||
r = np.append(r[0][np.tril_indices(lmax + 1)], r[1, 1:, 1:][np.tril_indices(lmax)]) | |||||
g = np.append(g[0][np.tril_indices(lmax + 1)], g[1, 1:, 1:][np.tril_indices(lmax)]) | |||||
b = np.append(b[0][np.tril_indices(lmax + 1)], b[1, 1:, 1:][np.tril_indices(lmax)]) | |||||
# Encode as string | |||||
maxVal = np.max([np.max(r), np.max(b), np.max(g)]) | |||||
vals = encodeCoeff(r, g, b, maxVal).flatten() | |||||
asstr = b83encode(vals, 2) | |||||
lmaxStr = b83encode([lmax], 1) | |||||
maxValStr = b83encode(encodeFloat([2 * maxVal / 255 - 1], 41), 1) | |||||
return lmaxStr + maxValStr + asstr | |||||
# Subclass parser to add explaination for semi-option nona flag | # Subclass parser to add explaination for semi-option nona flag | ||||
class GenParser(argparse.ArgumentParser): | class GenParser(argparse.ArgumentParser): | ||||
def error(self, message): | def error(self, message): | ||||
@@ -90,6 +145,8 @@ parser.add_argument('-q', '--quality', dest='quality', default=75, type=int, | |||||
help='output JPEG quality 0-100') | help='output JPEG quality 0-100') | ||||
parser.add_argument('--png', action='store_true', | parser.add_argument('--png', action='store_true', | ||||
help='output PNG tiles instead of JPEG tiles') | help='output PNG tiles instead of JPEG tiles') | ||||
parser.add_argument('--thumbnailsize', dest='thumbnailSize', default=0, type=int, | |||||
help='width of equirectangular thumbnail preview (defaults to no thumbnail; >512 not recommended)') | |||||
parser.add_argument('-n', '--nona', default=nona, required=nona is None, | parser.add_argument('-n', '--nona', default=nona, required=nona is None, | ||||
metavar='EXECUTABLE', | metavar='EXECUTABLE', | ||||
help='location of the nona executable to use') | help='location of the nona executable to use') | ||||
@@ -99,6 +156,7 @@ parser.add_argument('-d', '--debug', action='store_true', | |||||
help='debug mode (print status info and keep intermediate files)') | help='debug mode (print status info and keep intermediate files)') | ||||
args = parser.parse_args() | args = parser.parse_args() | ||||
# Create output directory | # Create output directory | ||||
if os.path.exists(args.output): | if os.path.exists(args.output): | ||||
print('Output directory "' + args.output + '" already exists') | print('Output directory "' + args.output + '" already exists') | ||||
@@ -229,6 +287,22 @@ if not args.debug: | |||||
if os.path.exists(os.path.join(args.output, face)): | if os.path.exists(os.path.join(args.output, face)): | ||||
os.remove(os.path.join(args.output, face)) | os.remove(os.path.join(args.output, face)) | ||||
# Generate preview (but not for partial panoramas) | |||||
if haov < 360 or vaov < 180: | |||||
genPreview = False | |||||
if genPreview: | |||||
# Generate SHT-hash preview | |||||
shtHash = img2shtHash(np.array(Image.open(args.inputFile))) | |||||
if args.thumbnailSize > 0: | |||||
# Create low-resolution base64-encoded equirectangular preview image | |||||
img = Image.open(args.inputFile) | |||||
img = img.resize((args.thumbnailSize, args.thumbnailSize // 2)) | |||||
buf = io.BytesIO() | |||||
img.save(buf, format='JPEG', quality=75, optimize=True) | |||||
equiPreview = bytes('data:image/jpeg;base64,', encoding='utf-8') | |||||
equiPreview += base64.b64encode(buf.getvalue()) | |||||
equiPreview = equiPreview.decode() | |||||
# Generate config file | # Generate config file | ||||
text = [] | text = [] | ||||
text.append('{') | text.append('{') | ||||
@@ -252,6 +326,10 @@ if args.autoload: | |||||
text.append(' "autoLoad": true,') | text.append(' "autoLoad": true,') | ||||
text.append(' "type": "multires",') | text.append(' "type": "multires",') | ||||
text.append(' "multiRes": {') | text.append(' "multiRes": {') | ||||
if genPreview: | |||||
text.append(' "shtHash": "' + shtHash + '",') | |||||
if args.thumbnailSize > 0: | |||||
text.append(' "equirectangularThumbnail": "' + equiPreview + '",') | |||||
text.append(' "path": "/%l/%s%y_%x",') | text.append(' "path": "/%l/%s%y_%x",') | ||||
text.append(' "fallbackPath": "/fallback/%s",') | text.append(' "fallbackPath": "/fallback/%s",') | ||||
text.append(' "extension": "' + extension[1:] + '",') | text.append(' "extension": "' + extension[1:] + '",') | ||||
@@ -16,11 +16,14 @@ or [Docker](https://www.docker.com/) can be used to avoid this installation. | |||||
### Option 1: with local dependencies | ### Option 1: with local dependencies | ||||
The `generate.py` script depends on `nona` (from [Hugin](http://hugin.sourceforge.net/)), | The `generate.py` script depends on `nona` (from [Hugin](http://hugin.sourceforge.net/)), | ||||
as well as Python with the [Pillow](https://pillow.readthedocs.org/) package. On Ubuntu, | |||||
these dependencies can be installed by running: | |||||
as well as Python 3 with the [Pillow](https://pillow.readthedocs.org/) and | |||||
[NumPy](https://numpy.org/) packages. The [pyshtools](https://shtools.github.io/SHTOOLS/) | |||||
Python package is also recommended. On Ubuntu, these dependencies can be | |||||
installed by running: | |||||
```bash | ```bash | ||||
$ sudo apt install python3 python3-pil hugin-tools | |||||
$ sudo apt install python3 python3-pil python3-numpy python3-pip hugin-tools | |||||
$ pip3 install --user pyshtools | |||||
``` | ``` | ||||
Once the dependencies are installed, a tileset can generated with: | Once the dependencies are installed, a tileset can generated with: | ||||