Pano Downloader

download panoramas from google

目前為 2025-03-10 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Pano Downloader
// @namespace    https://greasyfork.org/users/1179204
// @version      1.4.1
// @description  download panoramas from google
// @author       KaKa
// @match        https://map-making.app/maps/*
// @require      https://cdn.jsdelivr.net/npm/sweetalert2@11
// @license      MIT
// @icon         https://www.svgrepo.com/show/502638/download-photo.svg
// ==/UserScript==
(function() {
    'use strict';
    let zoomLevel

    function getSelection() {
        const activeSelections = unsafeWindow.editor.selections

        return activeSelections.flatMap(selection => selection.locations)
    }

    function stripBaidu(pano) {
        return pano.replace("BAIDU:", "");
    }

    function stripTencent(pano) {
        return pano.replace("TENCENT:", "");
    }

    async function runScript() {

        const { value: option,dismiss: inputDismiss } = await Swal.fire({
            title: 'Select Panoramas',
            text: 'Do you want to input the panoId from your selections on map-making? If you click "Cancel", you will need to upload a JSON file.',
            icon: 'question',
            showCancelButton: true,
            showCloseButton:true,
            allowOutsideClick: false,
            confirmButtonColor: '#3085d6',
            cancelButtonColor: '#d33',
            confirmButtonText: 'Yes',
            cancelButtonText: 'Cancel'
        });


        if (option) {

            const selectedLocs=getSelection()
            if(selectedLocs.length>0) {
                const { value: options, dismiss: inputDismiss } = await Swal.fire({
                    title: 'Download Options',
                    html:
                    '<select id="zoom-select" class="swal2-input" style="width:180px; height:40px; margin:5px; font-size:16px;white-space:prewrap">' +
                    '<option value="1">1 (100KB~500KB)</option>' +
                    '<option value="2">2 (500KB~1MB)</option>' +
                    '<option value="3">3 (1MB~4MB)</option>' +
                    '<option value="4">4 (4MB~8MB)</option>' +
                    '<option value="5">5 (8MB~24MB)</option>' +
                    '</select>'+
                    '<select id="img-select" class="swal2-input" style="width:180px; height:40px; margin:5px; font-size:16px;white-space:prewrap">' +
                    '<option value="2">Equirectangular</option>' +
                    '<option value="1">Perspective</option>' +
                    '</select>',
                    icon: 'question',
                    showCancelButton: true,
                    showCloseButton: true,
                    allowOutsideClick: false,
                    confirmButtonColor: '#3085d6',
                    cancelButtonColor: '#d33',
                    confirmButtonText: 'Yes',
                    cancelButtonText: 'Cancel',
                    preConfirm: () => {
                        return [document.getElementById('zoom-select').value,document.getElementById('img-select').value];
                    }
                });
                if (options){
                    zoomLevel=parseInt(options[0])
                    processData(selectedLocs,parseInt(options[1]))
                }}
        }

        else if(inputDismiss==='cancel'){

            const input = document.createElement('input');
            input.type = 'file';
            input.style.display = 'none'
            document.body.appendChild(input);

            const data = await new Promise((resolve) => {
                input.addEventListener('change', async () => {
                    const file = input.files[0];
                    const reader = new FileReader();

                    reader.onload = (event) => {
                        try {
                            const result = JSON.parse(event.target.result);
                            resolve(result);

                            document.body.removeChild(input);
                        } catch (error) {
                            Swal.fire('Error Parsing JSON Data!', 'The input JSON data is invalid or incorrectly formatted.','error');
                        }
                    };

                    reader.readAsText(file);
                });


                input.click();
            });
        }
        function generatePerspective(canvas, FOV, THETA, PHI, outputWidth, outputHeight) {
            const perspectiveCanvas = document.createElement('canvas');
            perspectiveCanvas.width = outputWidth;
            perspectiveCanvas.height = outputHeight;
            const perspectiveCtx = perspectiveCanvas.getContext('2d');

            const f = 0.5 * outputWidth / Math.tan((FOV / 2) * (Math.PI / 180));
            const cx = outputWidth / 2;
            const cy = outputHeight / 2;

            const inputWidth = canvas.width;
            const inputHeight = canvas.height;

            const inputCtx = canvas.getContext('2d');
            const inputImageData = inputCtx.getImageData(0, 0, inputWidth, inputHeight);

            const outputImageData = perspectiveCtx.createImageData(outputWidth, outputHeight);
            const outputData = outputImageData.data;

            for (let y = 0; y < outputHeight; y++) {
                for (let x = 0; x < outputWidth; x++) {
                    // 归一化屏幕坐标
                    const nx = (x - cx) / f;
                    const ny = (y - cy) / f;
                    const nz = 1;

                    // 构建旋转矩阵(修正反转问题)
                    const cosTheta = Math.cos(THETA);
                    const sinTheta = Math.sin(THETA);
                    const cosPhi = Math.cos(PHI);
                    const sinPhi = Math.sin(PHI);

                    const rx = cosTheta * nx - sinTheta * nz; // 修正镜像反转问题
                    const ry = -sinPhi * (sinTheta * nx + cosTheta * nz) + cosPhi * ny;
                    const rz = cosPhi * (sinTheta * nx + cosTheta * nz) + sinPhi * ny;

                    // 转换为球面坐标
                    const lon = Math.atan2(rx, rz);
                    const lat = Math.asin(ry / Math.sqrt(rx * rx + ry * ry + rz * rz));

                    // 将球面坐标映射到图像像素坐标
                    const u = Math.floor(((lon / (2 * Math.PI)) + 0.5) * inputWidth);
                    const v = Math.floor(((lat / Math.PI) + 0.5) * inputHeight);

                    if (u >= 0 && u < inputWidth && v >= 0 && v < inputHeight) {
                        const srcOffset = (v * inputWidth + u) * 4;
                        const destOffset = (y * outputWidth + x) * 4;

                        outputData[destOffset] = inputImageData.data[srcOffset];       // Red
                        outputData[destOffset + 1] = inputImageData.data[srcOffset + 1]; // Green
                        outputData[destOffset + 2] = inputImageData.data[srcOffset + 2]; // Blue
                        outputData[destOffset + 3] = 255;                             // Alpha
                    }
                }
            }

            perspectiveCtx.putImageData(outputImageData, 0, 0);
            return perspectiveCanvas;
        }
        async function downloadPanoramaImage(panoId, fileName,w,h,mode) {
            return new Promise(async (resolve, reject) => {
                try {
                    let canvas, ctx, tilesPerRow, tilesPerColumn, tileUrl, imageUrl;
                    const tileWidth = 512;
                    const tileHeight = 512;
                    if (panoId.includes('BAIDU')||panoId.includes('TENCENT')) {
                        tilesPerRow = 16;
                        tilesPerColumn = 8;
                    } else {
                        let zoomTiles;
                        imageUrl = `https://streetviewpixels-pa.googleapis.com/v1/tile?cb_client=apiv3&panoid=${panoId}&output=tile&zoom=${zoomLevel}&nbt=0&fover=2`;
                        zoomTiles = [2, 4, 8, 16, 32];
                        tilesPerRow = Math.min(Math.ceil(w / tileWidth), zoomTiles[zoomLevel - 1]);
                        tilesPerColumn = Math.min(Math.ceil(h / tileHeight), zoomTiles[zoomLevel - 1] / 2);

                    }

                    const canvasWidth = tilesPerRow * tileWidth;
                    const canvasHeight = tilesPerColumn * tileHeight;
                    canvas = document.createElement('canvas');
                    ctx = canvas.getContext('2d');
                    canvas.width = canvasWidth;
                    canvas.height = canvasHeight;

                    const loadTile = (x, y) => {
                        return new Promise(async (resolveTile) => {
                            let tile;
                            if (panoId.includes('TENCENT')) {
                                tileUrl = `https://sv4.map.qq.com/tile?svid=${stripTencent(panoId)}&x=${x}&y=${y}&from=web&level=1`;
                            } else if (panoId.includes('BAIDU')) {
                                tileUrl = `https://mapsv0.bdimg.com/?qt=pdata&sid=${stripBaidu(panoId)}&pos=${y}_${x}&z=5`;
                            } else {
                                tileUrl = `${imageUrl}&x=${x}&y=${y}`;
                            }

                            try {
                                tile = await loadImage(tileUrl);
                                ctx.drawImage(tile, x * tileWidth, y * tileHeight, tileWidth, tileHeight);
                                resolveTile();
                            } catch (error) {
                                console.error(`Error loading tile at ${x},${y}:`, error);
                                resolveTile();
                            }
                        });
                    };
                    let tilePromises = [];
                    for (let y = 0; y < tilesPerColumn; y++) {
                        for (let x = 0; x < tilesPerRow; x++) {
                            tilePromises.push(loadTile(x, y));
                        }
                    }

                    await Promise.all(tilePromises);

                    if(mode===1){
                        const perspectiveCanvas = generatePerspective(canvas, 90, 0, 0, w===13312?1920:1745, w===13312?1080:981);

                        perspectiveCanvas.toBlob(blob => {
                            const url = window.URL.createObjectURL(blob);
                            const a = document.createElement('a');
                            a.href = url;
                            a.download = fileName;
                            document.body.appendChild(a);
                            a.click();
                            document.body.removeChild(a);
                            window.URL.revokeObjectURL(url);
                            resolve();
                        }, 'image/jpeg');}
                    else{
                        canvas.toBlob(blob => {
                            const url = window.URL.createObjectURL(blob);
                            const a = document.createElement('a');
                            a.href = url;
                            a.download = fileName;
                            document.body.appendChild(a);
                            a.click(); document.body.removeChild(a);
                            window.URL.revokeObjectURL(url);
                            resolve();
                        }, 'image/jpeg');
                    }
                } catch (error) {
                    Swal.fire('Error!', error.toString(),'error');
                    reject(error);
                }
            });
        }

        async function loadImage(url) {
            return new Promise((resolve, reject) => {
                const img = new Image();
                img.crossOrigin = 'Anonymous';
                img.onload = () => resolve(img);
                img.onerror = () => reject(new Error(`Failed to load image from ${url}`));
                img.src = url;
            });
        }

        var CHUNK_SIZE = Math.round(20/zoomLevel);
        var promises = [];

        async function processChunk(chunk,mode) {
            var service = new google.maps.StreetViewService();
            var promises = chunk.map(async coord => {
                let panoId = coord.panoId;
                let latLng = {lat: coord.location.lat, lng: coord.location.lng};
                let svData;

                if ((panoId || latLng)) {
                    svData = await getSVData(service, panoId ? {pano: panoId} : {location: latLng, radius: 5});
                }

                if (svData.tiles&&svData.tiles.worldSize) {
                    const w=svData.tiles.worldSize.width
                    const h=svData.tiles.worldSize.height
                    const fileName = `${panoId}.jpg`;
                    await downloadPanoramaImage(panoId, fileName,w,h,mode);
                }

            });

            await Promise.all(promises);
        }

        function getSVData(service, options) {
            return new Promise(resolve => service.getPanorama({...options}, (data, status) => {
                resolve(data);
            }));
        }

        async function processData(panos,mode) {
            try {
                let processedChunks = 0;
                const swal = Swal.fire({
                    title: 'Downloading',
                    text: 'Please wait...',
                    allowOutsideClick: false,
                    allowEscapeKey: false,
                    showConfirmButton: false,
                    didOpen: () => {
                        Swal.showLoading();
                    }
                });
                for (let i = 0; i < panos.length; i += 5) {
                    let chunk = panos.slice(i, i + 5);
                    await processChunk(chunk,mode);
                    processedChunks++;
                    const progress = Math.min((processedChunks /panos.length) * 100, 100);
                    Swal.update({
                        html: `<div>${progress.toFixed(2)}% completed</div>
                       <div class="swal2-progress">
                           <div class="swal2-progress-bar" role="progressbar" aria-valuenow="${progress}" aria-valuemin="0" aria-valuemax="100" style="width: ${progress}%;">
                           </div>
                       </div>`
                    });
                }
                swal.close();
                Swal.fire('Success!','Download completed', 'success');
            } catch (error) {
                swal.close();
                Swal.fire('Error!',"Failed to download due to:"+error.toString(),'error');
            }
        }
    }

    var downloadButton=document.createElement('button');
    downloadButton.textContent='Download Panos'
    downloadButton.addEventListener('click', runScript);
    downloadButton.style.width='160px'
    downloadButton.style.position = 'fixed';
    downloadButton.style.right = '150px';
    downloadButton.style.bottom = '15px';
    downloadButton.style.borderRadius = "18px";
    downloadButton.style.padding = "5px 10px";
    downloadButton.style.border = "none";
    downloadButton.style.color = "white";
    downloadButton.style.cursor = "pointer";
    downloadButton.style.backgroundColor = "#4CAF50";
    document.body.appendChild(downloadButton);

})();