canvas像素操作一张全景图转立方体六面图片

124 阅读14分钟

全景图转立方体六面图片

  • 作用:在球体上贴一张全景图,图片太大时可能会有性能问题,切成多张图并行加载速度会快一点。

项目构建

  • 看vuejs教程
  • npm create vue@latest
  • npm install -D sass
  • threejs依赖
    • npm install three
    • npm install -D @types/three

核心文件

入口~Index.vue

<script setup lang="ts">
import { ref } from 'vue'
import ImgSelect from './components/ImgSelect.vue'
import SixFaceView from './components/SixFaceView.vue'
import CssCube from './components/CssCube.vue'
import ThreeDCube from './components/ThreeDCube.vue'

const threeDCubeRef = ref<InstanceType<typeof ThreeDCube>>();
const cssCubeRef = ref<InstanceType<typeof CssCube>>();
const sixFaceViewRef = ref<InstanceType<typeof SixFaceView>>();
// 选择图片改变
const selectChange = (file: File)=>{
    if(sixFaceViewRef.value){
        sixFaceViewRef.value?.selectFileChange(file);
    }
}
// 图片url改变
const changeUrl = (imgUrlArr:Array<string>)=>{
    const [pxUrl, nxUrl, nyUrl, pyUrl, nzUrl, pzUrl] = imgUrlArr;
    cssCubeRef.value?.imgUrlChange([pxUrl, nxUrl, nyUrl, pyUrl, nzUrl, pzUrl]);
    threeDCubeRef.value?.setCubeImg([pxUrl, nxUrl, pyUrl, nyUrl, pzUrl, nzUrl]);
}
</script>

<template>
    <div class="content-in">
        <div class="left">
            <div class="left-top">
                <ImgSelect @change="selectChange"/>
            </div>
            <div class="left-bottom">
                <SixFaceView ref="sixFaceViewRef" @change="changeUrl"/>
            </div>
        </div>
        <div class="right">
            <div class="right-top">
                <CssCube  ref="cssCubeRef"/>
            </div>
            <div class="right-bottom">
                <ThreeDCube ref="threeDCubeRef"/>
            </div>
        </div>
    </div>
</template>

<style scoped lang="scss">
.content-in {
    flex: 1;
    display: flex;
    overflow: hidden;

    .left {
        flex: 1;
        display: flex;
        flex-direction: column;
        overflow: hidden;

        .left-top {
            flex: 1;
            padding: 10px;
            position: relative;
            overflow: hidden;
        }

        .left-bottom {
            flex: 2;
            position: relative;
            overflow: auto;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            
        }
    }

    .right {
        flex: 1;
        display: flex;
        flex-direction: column;
        overflow: hidden;

        .right-top {
            flex: 1;
            position: relative;
            overflow: hidden;
            display: flex;
            align-items: center;
            justify-content: center;
        }

        .right-bottom {
            flex: 1;
            position: relative;
            overflow: hidden;
        }
    }

}
</style>

组件~ImgSelect.vue

<script setup lang="ts">
import { ref } from 'vue'

const emit = defineEmits(['change']);// 事件

const dragFileDiv = ref<HTMLDivElement>();
const imgFileRef = ref<HTMLImageElement>();
const inputFileRef = ref<HTMLInputElement>();
const h1Ref = ref<HTMLHeadElement>();

// 文件拖动到元素上方触发
const dragoverFile = (e: DragEvent) => {
    e.preventDefault();// 阻止默认行为,拖动后打开文件
    dragFileDiv.value?.classList.add('drag-over');
}

// 当用户释放鼠标按钮,将文件放到元素上时触发
const dropFile = (e: DragEvent) => {
    e.preventDefault();// 阻止默认行为,拖动后打开文件
    dragFileDiv.value?.classList.remove('drag-over');
    if (e.dataTransfer && e.dataTransfer.files) {
        handleFile(e.dataTransfer.files[0])
    }
}

// 文件离开元素上方触发
const dragleaveFile = (e: DragEvent) => {
    e.preventDefault(); // 阻止默认行为,拖动后打开文件
    dragFileDiv.value?.classList.remove('drag-over');
}
// 点击时触发
const clickSelectFile = (e: MouseEvent) => {
    e.preventDefault(); // 阻止默认行为
    inputFileRef.value?.click();
}
// 文件选择改变
const changeFileInput = (e: Event) => {
    const target = e.target as HTMLInputElement;
    if (target && target.files) {
        handleFile(target.files[0]);
    }
}
/**
 * 处理文件
 * @param file 文件
 */
function handleFile(file: File) {
    if (!file) {
        return;
    }
    const fileType = file.name.split(".").pop() || "";
    if (!["png", "jpg", "jpeg"].includes(fileType.toLowerCase()) || file.size > 1024 * 1024 * 50) {
        alert("请上传小于10M的 png, jpg,jpeg文件");
        return;
    }
    createObjectUrl(file)
    // createDateUrl(file)
    emit('change', file);
}
/**
 * Data URL
 * 优点:生成的 data URL 是文件的一个完整的 Base64 编码的字符串表示,可以直接用于 img 标签的 src 属性或其他需要文件数据的地方。
 * 缺点:转换大文件为 Base64 可能会消耗较多的内存和 CPU 资源,因为需要读取整个文件内容并编码为一个长字符串。对于大文件,这可能导致性能问题。
 * Object URL
 * 优点:使用 URL.createObjectURL() 创建的对象 URL 仅是一个指向文件的引用,不需要读取和存储文件的全部内容。这使得它处理大文件时更有效,速度更快,资源占用更低。
 * 缺点:生命周期管理。当不再需要这些 URL 时,必须手动调用 URL.revokeObjectURL() 来释放内存。此外,它只能用于浏览器环境,因为它生成的是一个指向本地文件的引用。
 * @param file 
 */
// function createDateUrl(file: File) {
//     const fileReader = new FileReader();
//     // 监听读取完成
//     fileReader.addEventListener("load",(e) => {
//         // 生成的dataUrl在这里
//         displayImg(e.target.result);
//     })
//     // 读取文件(所有的IO操作都是异步的)
//     fileReader.readAsDataURL(file);
// }
function createObjectUrl(file: File) {
    const url = URL.createObjectURL(file)
    displayImg(url)
}
function displayImg(url: string) {
    console.log(url)
    if (!imgFileRef.value) {
        return;
    }
    imgFileRef.value.src = url;
    imgFileRef.value.style.display = "block";
    if (h1Ref.value) {
        h1Ref.value.style.display = "none";
    }
    if (dragFileDiv.value) {
        dragFileDiv.value.style.border = "none";
    }
    // URL.revokeObjectURL(url);
}
</script>

<template>
    <input ref="inputFileRef" @change="changeFileInput" type="file" accept=".png,jpg,.jpeg">
    <div ref="dragFileDiv" @dragover="dragoverFile" @drop="dropFile" @dragleave="dragleaveFile"
        @click="clickSelectFile" class="img-input">
        <img ref="imgFileRef" />
        <h1 ref="h1Ref">请点击或者拖拽文件到该区域</h1>
    </div>
</template>

<style scoped lang="scss">
input {
    display: none;
}

.img-input {
    width: 100%;
    height: 100%;
    border: 2px dashed #131212;
    border-radius: 5px;
    display: flex;
    align-items: center;
    justify-content: center;
    user-select: none;

    img {
        width: 100%;
        height: 100%;
        display: none;
        pointer-events: none; // 不能被鼠标选中
    }

    h1 {
        position: absolute;
        pointer-events: none; // 不能被鼠标选中
    }
}

.drag-over {
    background-color: rgb(240, 227, 204);
}
</style>

组件~SixFaceView.vue

<script setup lang="ts">
import { ref } from 'vue'
import { get6FaceImgUrl, downloadUrlFile } from '../../../utils/ConvertPanorama.ts'
const emit = defineEmits(['change']);// 事件

const imgPzRef = ref<HTMLImageElement>();
const imgNzRef = ref<HTMLImageElement>();
const imgPxRef = ref<HTMLImageElement>();
const imgNxRef = ref<HTMLImageElement>();
const imgPyRef = ref<HTMLImageElement>();
const imgNyRef = ref<HTMLImageElement>();
const h1Ref = ref<HTMLHeadElement>();
const imgUrlArr = ref<Array<string>>([]);
// 选择图片改变
const selectFileChange = async (file: File) => {
    console.log(111)
    if (h1Ref.value) {
        h1Ref.value.innerHTML = "解析中~~~";
    }
    const [pxUrl, nxUrl, nyUrl, pyUrl, nzUrl, pzUrl] = await get6FaceImgUrl(file);
    console.log(pxUrl, nxUrl, nyUrl, pyUrl, nzUrl, pzUrl)
    imgUrlArr.value = [pxUrl, nxUrl, nyUrl, pyUrl, nzUrl, pzUrl];
    emit('change', [pxUrl, nxUrl, nyUrl, pyUrl, nzUrl, pzUrl]);
    if (imgPzRef.value) {
        imgPzRef.value.src = pzUrl;
    }
    if (imgNzRef.value) {
        imgNzRef.value.src = nzUrl;
    }
    if (imgPxRef.value) {
        imgPxRef.value.src = pxUrl;
    }
    if (imgNxRef.value) {
        imgNxRef.value.src = nxUrl;
    }
    if (imgPyRef.value) {
        imgPyRef.value.src = pyUrl;
    }
    if (imgNyRef.value) {
        imgNyRef.value.src = nyUrl;
    }
    if (h1Ref.value) {
        h1Ref.value.innerHTML = "解析完成~~~";
    }
}
// 下载文件
const downloadFile = (face: string) => {
    const [pxUrl, nxUrl, nyUrl, pyUrl, nzUrl, pzUrl] = imgUrlArr.value;
    switch (face) {
        case 'pz': {
            downloadUrlFile(pzUrl, `${face}.png`);
            break;
        }
        case 'nz': {
            downloadUrlFile(nzUrl, `${face}.png`);
            break;
        }
        case 'px': {
            downloadUrlFile(pxUrl, `${face}.png`);
            break;
        }
        case 'nx': {
            downloadUrlFile(nxUrl, `${face}.png`);
            break;
        }
        case 'py': {
            downloadUrlFile(pyUrl, `${face}.png`);
            break;
        }
        case 'ny': {
            downloadUrlFile(nyUrl, `${face}.png`);
            break;
        }
    }
}
/**
 * 暴露方法给父组件调用
 */
 defineExpose({
    selectFileChange
})
</script>

<template>
    <h1 ref="h1Ref"></h1>
    <div class="cube-img-area">
        <div class="cube-face pz" @click="downloadFile('pz')">
            <h1>pz</h1>
            <img ref="imgPzRef" />
        </div>
        <div class="cube-face nz" @click="downloadFile('nz')">
            <h1>nz</h1>
            <img ref="imgNzRef" />
        </div>
        <div class="cube-face px" @click="downloadFile('px')">
            <h1>px</h1>
            <img ref="imgPxRef" />
        </div>
        <div class="cube-face nx" @click="downloadFile('nx')">
            <h1>nx</h1>
            <img ref="imgNxRef" />
        </div>
        <div class="cube-face py" @click="downloadFile('py')">
            <h1>py</h1>
            <img ref="imgPyRef" />
        </div>
        <div class="cube-face ny" @click="downloadFile('ny')">
            <h1>ny</h1>
            <img ref="imgNyRef" />
        </div>
    </div>
</template>

<style scoped lang="scss">
.cube-img-area {
    position: relative;
    width: 415px;
    height: 310px;
    overflow: hidden;
    user-select: none;

    .cube-face {
        width: 100px;
        height: 100px;
        overflow: hidden;
        border: none;
        display: flex;
        align-items: center;
        justify-content: center;
        cursor: pointer;
        h1{
            position: absolute;
            z-index: 100;
            color: #ffffff;
            pointer-events: none; // 不能被鼠标选中
            user-select: none;
        }

        img {
            width: 100%;
            height: 100%;
            pointer-events: none; // 不能被鼠标选中
            border: none;
        }
    }

    .pz {
        position: absolute;
        left: 105px;
        top: 105px;
    }

    .nz {
        position: absolute;
        left: 315px;
        top: 105px;
    }

    .py {
        position: absolute;
        left: 105px;
        top: 0px;
    }

    .ny {
        position: absolute;
        left: 105px;
        top: 210px;
    }

    .px {
        position: absolute;
        left: 0px;
        top: 105px;
    }

    .nx {
        position: absolute;
        left: 210px;
        top: 105px;
    }
}
</style>

组件~CssCube.vue

<script setup lang="ts">
import { ref } from 'vue'

const imgPzRef = ref<HTMLImageElement>();
const imgNzRef = ref<HTMLImageElement>();
const imgPxRef = ref<HTMLImageElement>();
const imgNxRef = ref<HTMLImageElement>();
const imgPyRef = ref<HTMLImageElement>();
const imgNyRef = ref<HTMLImageElement>();

// 选择图片改变
const imgUrlChange = (imgUrlArr:Array<string>) => {
    const [pxUrl, nxUrl, nyUrl, pyUrl, nzUrl, pzUrl] = imgUrlArr;
    if (imgPzRef.value) {
        imgPzRef.value.src = pzUrl;
    }
    if (imgNzRef.value) {
        imgNzRef.value.src = nzUrl;
    }
    if (imgPxRef.value) {
        imgPxRef.value.src = pxUrl;
    }
    if (imgNxRef.value) {
        imgNxRef.value.src = nxUrl;
    }
    if (imgPyRef.value) {
        imgPyRef.value.src = pyUrl;
    }
    if (imgNyRef.value) {
        imgNyRef.value.src = nyUrl;
    }
}
/**
 * 暴露方法给父组件调用
 */
 defineExpose({
    imgUrlChange
})
</script>

<template>
    <div class="loader3d">
        <div class="cube">
            <div class="face ny">
                <h1>ny</h1>
                <img ref="imgNyRef" />
            </div>
            <div class="face py">
                <h1>py</h1>
                <img ref="imgPyRef" />
            </div>
            <div class="face nx">
                <h1>nx</h1>
                <img ref="imgNxRef" />
            </div>
            <div class="face px">
                <h1>px</h1>
                <img ref="imgPxRef" />
            </div>
            <div class="face nz">
                <h1>nz</h1>
                <img ref="imgNzRef" />
            </div>
            <div class="face pz">
                <h1>pz</h1>
                <img ref="imgPzRef" />
            </div>
        </div>
    </div>
</template>

<style scoped lang="scss">
.loader3d {
    perspective: 600px;
    width: 200px;
    height: 200px;
    user-select: none;
    pointer-events: none; // 不能被鼠标选中
}

.cube {
    width: 100%;
    height: 100%;
    transform-style: preserve-3d;
    animation: rotate 10s linear infinite;
}

.face {
    position: absolute;
    width: 100%;
    height: 100%;
    // background: linear-gradient(45deg, #3498db, #e74c3c);
    // opacity: 0.8;
    border: 0.5px solid #fff;
    // border-radius: 25%;
    text-align: center;
    line-height: 200px;
    display: flex;
    align-items: center;
    justify-content: center;
    h1{
        position: absolute;
        z-index: 100;
        color: #ffffff;
        pointer-events: none; // 不能被鼠标选中
        user-select: none;
    }
    img {
        width: 100%;
        height: 100%;
        border: 0.5px solid #fff;
        // border-radius: 25%;
    }
}
.py {
    transform: rotateX(90deg) translateZ(100px);
}

.ny {
    transform: rotateX(-90deg) translateZ(100px);
}

.pz {
    transform: translateZ(100px);
}

.nx {
    transform: rotateY(90deg) translateZ(100px);
}

.px {
    transform: rotateY(-90deg) translateZ(100px);
}

.nz {
    transform: rotateY(180deg) translateZ(100px);
}

@keyframes rotate {
    0% {
        transform: rotateX(0deg) rotateY(0deg);
    }

    100% {
        transform: rotateX(360deg) rotateY(360deg);
    }
}
</style>

组件~ThreeDCube.vue

<script setup lang="ts">
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { ref, onBeforeUnmount, onMounted } from 'vue'

const threeRef = ref<HTMLDivElement>();

let camera: THREE.PerspectiveCamera, controls: OrbitControls;// 相机、控制器
let renderer: THREE.WebGLRenderer;// 渲染器
let scene: THREE.Scene;// 场景
// 初始化
function init() {
    if (!threeRef.value) {
        console.error('threeRef找不到')
        return;
    }
    const divWidth = threeRef.value.offsetWidth;
    const divHeight = threeRef.value.offsetHeight;

    // 1、场景
    scene = new THREE.Scene();
    // 2、相机
    camera = new THREE.PerspectiveCamera(70, divWidth / divHeight, 0.1, 10);
    camera.position.set(0, 0, 0.1);
    // 3、初始化渲染器
    renderer = new THREE.WebGLRenderer();
    renderer.setPixelRatio(window.devicePixelRatio);// 设备的物理像素分辨率
    renderer.setSize(divWidth, divHeight);// 设置显示区域大小
    renderer.setAnimationLoop(() => {
        controls.update(); // 启用阻尼时需要
        renderer.render(scene, camera);// 渲染
    });// 循环函数
    threeRef.value.appendChild(renderer.domElement);// 添加到显示区域
    // 4、控制器
    controls = new OrbitControls(camera, renderer.domElement);
    // controls.enableZoom = false;
    // controls.enablePan = false;
    // controls.enableDamping = true;
    // controls.rotateSpeed = - 0.25;
    controls.addEventListener('start', () => {
        // console.log('controls start')
        renderer.domElement.style.cursor = 'move'
    })
    controls.addEventListener('end', () => {
        // console.log('controls end')
        renderer.domElement.style.cursor = 'auto'
    })
}
// 销毁
function destory() {
    try {
        console.log('---->全景图-->destroy')
        /**
         * 渲染器
         */
        renderer?.domElement?.parentElement?.removeChild(renderer?.domElement);
        renderer.setAnimationLoop(null);
        renderer.dispose();
        renderer.forceContextLoss();
        const gl = renderer.domElement.getContext("webgl");
        if (gl && gl.getExtension("WEBGL_lose_context")) {
            gl.getExtension("WEBGL_lose_context")?.loseContext();
        }
        scene.traverse((child: any) => {
            if (child.material) {
                // 检查是否有dispose方法
                if (child.material.dispose) {
                    child.material.dispose();
                }
                child.material = null;
            }
            if (child.geometry) {
                if (child.geometry.dispose) {
                    child.geometry.dispose();
                }
                child.geometry = null;
                if (child.bufferGeometry && child.bufferGeometry.dispose) {
                    child.bufferGeometry.dispose();
                    child.bufferGeometry = null;
                }
            }
        });
        scene.clear();
    } catch (e) {
        console.error(e);
    }
}
// 添加cube
function setCubeImg([left, right, up, down, back, front]: Array<string>) {
    // 加载图片纹理
    const textures: Array<THREE.Texture> = [];
    [left, right, up, down, back, front].forEach(i => {
        const texture = new THREE.TextureLoader().load(i);
        texture.colorSpace = THREE.SRGBColorSpace;
        textures.push(texture);
    });
    // 材质
    const materials = [];
    for (let i = 0; i < 6; i++) {
        materials.push(new THREE.MeshBasicMaterial({ map: textures[i] }));
    }
    // 检查场景中是否已经存在立方体
    let skyBox:THREE.Mesh = scene.getObjectByName('skyBox') as THREE.Mesh;
    if (skyBox) {
        // 如果存在,替换其材质
        skyBox.material = materials;
        for (let i = 0; i < 6; i++) {
            skyBox.material[i].needsUpdate = true; // 确保材质更新
        }
    } else {
        // 如果不存在,新建一个立方体
        skyBox = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), materials);
        skyBox.name = 'skyBox'; // 设置名称以便后续查找
        skyBox.geometry.scale(1, 1, -1);
        scene.add(skyBox);
    }
}
/**
 * 暴露方法给父组件调用
 */
defineExpose({
    setCubeImg
})
onMounted(() => init())
onBeforeUnmount(() => destory())
</script>

<template>
    <div ref="threeRef" class="three"></div>
</template>

<style scoped lang="scss">
.three {
    width: 100%;
    height: 100%;
}
</style>

核心工具类~ConvertPanorama.ts

/**
 * 将给定的x值限制在最小值和最大值之间
 * @param {number} x 给定的值
 * @param {number} min 最小值
 * @param {number} max 最大值
 * @returns 返回目标值
 */
const clamp = (x: number, min: number, max: number): number => {
  return Math.min(max, Math.max(x, min));
}

/**
 * 模运算(取余运算)的实现
 * 但它进行了一些额外的处理以确保结果始终是非负的,即使输入的 x 是一个负数。
 * 在标准的取余运算中,如果 x 是负数,那么结果也可能是负数。
 * 但这个 mod 函数通过添加 n 并再次取模来确保结果总是在 0 到 n-1 的范围内。
 * @param {number} x 要进行模运算的数值
 * @param {number} n 模数,通常是一个正整数
 * @returns 
 */
const mod = (x: number, n: number): number => {
  return ((x % n) + n) % n;
}

// 定义各个面枚举类型
export enum FaceEnum {
  PZ,// 正Z面(面向观察者的面)
  NZ,// 负Z面(背向观察者的面)
  PX,// 正X面(观察者的右侧面)
  NX,// 负X面(观察者的左侧面)
  PY,// 正Y面(观察者的上方面)
  NY,// 负Y面(观察者的下方面)
};
// 三维向量
type Vector3 = { x: number, y: number, z: number };
// 取向函数接口
type OrientationFunction = (a: number, b: number) => Vector3;
// 各个面取向函数映射对象‌
const orientations: { [key in FaceEnum]: OrientationFunction } = {
  [FaceEnum.PZ]: (x: number, y: number) => ({ x: -1, y: -x, z: -y }),
  [FaceEnum.NZ]: (x: number, y: number) => ({ x: 1, y: x, z: -y }),
  [FaceEnum.PX]: (x: number, y: number) => ({ x: x, y: -1, z: -y }),
  [FaceEnum.NX]: (x: number, y: number) => ({ x: -x, y: 1, z: -y }),
  [FaceEnum.PY]: (x: number, y: number) => ({ x: -y, y: -x, z: 1 }),
  [FaceEnum.NY]: (x: number, y: number) => ({ x: y, y: -x, z: -1 })
};
// 插值方式枚举
export enum InterpolationEnum {
  Bilinear,// 双线性插值,使用目标像素周围四个源像素的线性组合进行插值,在单个方向上线性,整体非线性,插值效果较平滑
  Bicubic,// 双立方/双三次插值,使用更复杂的三次卷积公式,考虑周围更多像素的影响,插值效果通常比双线性更好,但计算量更大
  Lanczos,// Lanczos插值,适用于高质量图像重采样,但计算复杂度较高
  Nearest// 最近邻插值,选择离目标像素最近的源像素值作为插值结果,方法简单快速,但可能导致图像锯齿化
}
// 插值方式函数接口
type InterpolationFunction = (read: ImageData, write: ImageData, xFrom: number, yFrom: number, to: number) => void;
type KernelFunction = (x: number) => number;
// 各个插值方式函数映射对象‌
const interpolations: { [key in InterpolationEnum]: InterpolationFunction } = {
  [InterpolationEnum.Bilinear]: copyPixelBilinear,
  [InterpolationEnum.Bicubic]: copyPixelBicubic,
  [InterpolationEnum.Lanczos]: copyPixelLanczos,
  [InterpolationEnum.Nearest]: copyPixelNearest
};
/**
 * 最近邻插值,选择离目标像素最近的源像素值作为插值结果,方法简单快速,但可能导致图像锯齿化
 * @param {ImageData} read 输入
 * @param {ImageData} write 输出
 * @param {number} xFrom x坐标
 * @param {number} yFrom y坐标
 * @param {number} to 结束坐标
 */
function copyPixelNearest(read: ImageData, write: ImageData, xFrom: number, yFrom: number, to: number) {
  const { width, height, data } = read;
  const readIndex = (x: number, y: number) => 4 * (y * width + x);

  const nearest = readIndex(
    clamp(Math.round(xFrom), 0, width - 1),
    clamp(Math.round(yFrom), 0, height - 1)
  );

  for (let channel = 0; channel < 3; channel++) {
    write.data[to + channel] = data[nearest + channel];
  }
}

/**
 * 双线性插值,使用目标像素周围四个源像素的线性组合进行插值,在单个方向上线性,整体非线性,插值效果较平滑
 * @param {ImageData} read 输入
 * @param {ImageData} write 输出
 * @param {number} xFrom x坐标
 * @param {number} yFrom y坐标
 * @param {number} to 结束坐标
 */
function copyPixelBilinear(read: ImageData, write: ImageData, xFrom: number, yFrom: number, to: number) {
  const { width, height, data } = read;
  const readIndex = (x: number, y: number) => 4 * (y * width + x);

  const xl = clamp(Math.floor(xFrom), 0, width - 1);
  const xr = clamp(Math.ceil(xFrom), 0, width - 1);
  const xf = xFrom - xl;

  const yl = clamp(Math.floor(yFrom), 0, height - 1);
  const yr = clamp(Math.ceil(yFrom), 0, height - 1);
  const yf = yFrom - yl;

  const p00 = readIndex(xl, yl);
  const p10 = readIndex(xr, yl);
  const p01 = readIndex(xl, yr);
  const p11 = readIndex(xr, yr);

  for (let channel = 0; channel < 3; channel++) {
    const p0 = data[p00 + channel] * (1 - xf) + data[p10 + channel] * xf;
    const p1 = data[p01 + channel] * (1 - xf) + data[p11 + channel] * xf;
    write.data[to + channel] = Math.ceil(p0 * (1 - yf) + p1 * yf);
  }
}

/**
 * 图像重采样(针对双线性或更高级的插值方法进行了泛化处理)
 * @param {ImageData} read 输入
 * @param {ImageData} write 输出
 * @param {number} xFrom x坐标
 * @param {number} yFrom y坐标
 * @param {number} to 结束坐标
 * @param {number} filterSize 插值滤波器的大小(半径),决定了在重采样过程中将考虑多少周围像素
 * @param {number} kernel 一个函数,用于计算给定偏移量的插值权重
 */
function kernelResample(read: ImageData, write: ImageData, xFrom: number, yFrom: number, to: number, filterSize: number, kernel: KernelFunction) {
  const { width, height, data } = read;
  const readIndex = (x: number, y: number) => 4 * (y * width + x);

  const twoFilterSize = 2 * filterSize;
  const xMax = width - 1;
  const yMax = height - 1;
  const xKernel = new Array(4);
  const yKernel = new Array(4);

  const xl = Math.floor(xFrom);
  const yl = Math.floor(yFrom);
  const xStart = xl - filterSize + 1;
  const yStart = yl - filterSize + 1;

  for (let i = 0; i < twoFilterSize; i++) {
    xKernel[i] = kernel(xFrom - (xStart + i));
    yKernel[i] = kernel(yFrom - (yStart + i));
  }

  for (let channel = 0; channel < 3; channel++) {
    let q = 0;

    for (let i = 0; i < twoFilterSize; i++) {
      const y = yStart + i;
      const yClamped = clamp(y, 0, yMax);
      let p = 0;
      for (let j = 0; j < twoFilterSize; j++) {
        const x = xStart + j;
        const index = readIndex(clamp(x, 0, xMax), yClamped);
        p += data[index + channel] * xKernel[j];

      }
      q += p * yKernel[i];
    }

    write.data[to + channel] = Math.round(q);
  }
}

/**
 * 双立方/双三次插值,使用更复杂的三次卷积公式,考虑周围更多像素的影响,插值效果通常比双线性更好,但计算量更大
 * @param {ImageData} read 输入
 * @param {ImageData} write 输出
 * @param {number} xFrom x坐标
 * @param {number} yFrom y坐标
 * @param {number} to 结束坐标
 */
function copyPixelBicubic(read: ImageData, write: ImageData, xFrom: number, yFrom: number, to: number) {
  const b = -0.5;
  const kernel = (x: number) => {
    x = Math.abs(x);
    const x2 = x * x;
    const x3 = x * x * x;
    return x <= 1 ?
      (b + 2) * x3 - (b + 3) * x2 + 1 :
      b * x3 - 5 * b * x2 + 8 * b * x - 4 * b;
  };
  kernelResample(read, write, xFrom, yFrom, to, 2, kernel);
}

/**
 * Lanczos插值,适用于高质量图像重采样,但计算复杂度较高
 * @param {ImageData} read 输入
 * @param {ImageData} write 输出
 * @param {number} xFrom x坐标
 * @param {number} yFrom y坐标
 * @param {number} to 结束坐标
 */
function copyPixelLanczos(read: ImageData, write: ImageData, xFrom: number, yFrom: number, to: number) {
  const filterSize = 5;
  const kernel = (x: number) => {
    if (x === 0) {
      return 1;
    }
    else {
      const xp = Math.PI * x;
      return filterSize * Math.sin(xp) * Math.sin(xp / filterSize) / (xp * xp);
    }
  };
  kernelResample(read, write, xFrom, yFrom, to, filterSize, kernel);
}

/**
 * 复制对应面图像数据
 * @param {ImageData} readData canvas获取的全景图像素数据
 * @param {FaceEnum} face 面
 * @param {number} rotation 旋转角度
 * @param {InterpolationEnum} interpolation 插值方式
 * @param {number} maxWidth 返回图片最大宽度
 */
export function copyFaceData(readData: ImageData, face: FaceEnum, rotation: number, interpolation: InterpolationEnum, maxWidth = Infinity): ImageData {
  // 图片宽高
  const faceWidth = Math.min(maxWidth, readData.width / 4);
  const faceHeight = faceWidth;
  // 图像数据
  const writeData = new ImageData(faceWidth, faceHeight);

  for (let x = 0; x < faceWidth; x++) {
    for (let y = 0; y < faceHeight; y++) {
      const to = 4 * (y * faceWidth + x);// 计算Alpha(rgba透明度)坐标
      writeData.data[to + 3] = 255;// 填充Alpha(rgba透明度)通道:完全不透明
      const cube: Vector3 = orientations[face]((2 * (x + 0.5) / faceWidth - 1), (2 * (y + 0.5) / faceHeight - 1));// 在立方体表面上获得位置,立方体以原点为中心,边长为2
      // 直角坐标球坐标变换法将立方体面投影到单位球面上
      const r = Math.sqrt(cube.x * cube.x + cube.y * cube.y + cube.z * cube.z);// 计算半径
      const lon = mod(Math.atan2(cube.y, cube.x) + rotation, 2 * Math.PI);
      const lat = Math.acos(cube.z / r);
      // 不同插值方式->复制像素的方法
      interpolations[interpolation](readData, writeData, readData.width * lon / Math.PI / 2 - 0.5, readData.height * lat / Math.PI - 0.5, to);
    }
  }

  return writeData;
}

// 图片类型
const mimeType: { [key in string]: string } = {
  'jpg': 'image/jpeg',
  'png': 'image/png'
};
/**
 * 图像数据转Blob
 * 可用于图片上传
 * @param {ImageData} imageData 图像数据对象
 * @param {string} extension 图片扩展名
 */
export function imgDataToBlob(imageData: ImageData, extension: string): Promise<Blob> {
  const canvas = document.createElement('canvas');
  // 设置canvas的尺寸以匹配ImageData的尺寸
  canvas.width = imageData.width;
  canvas.height = imageData.height;
  const ctx = canvas.getContext('2d');// 获取2D绘图上下文
  // 将ImageData绘制到canvas上
  ctx?.putImageData(imageData, 0, 0);
  // 法一:是同步的,返回Base64编码的字符串通常比原始的二进制数据要大,这可能会增加内存占用。
  // const dataURL = canvas.toDataURL();// 获取数据URL(默认格式为PNG)
  // const dataURL = canvas.toDataURL('image/jpeg', 0.8); // 0.8是质量设置,范围为0到1(如果你想指定其他格式(比如JPEG),你可以这样做)
  // 法二:异步,图片blob数据
  return new Promise((resolve, reject) => {
    canvas.toBlob(blob => blob ? resolve(blob) : reject("blob is null..."), mimeType[extension], 0.92);
  });
}
/**
 * 图片文件对象转ImageData
 * @param file 图片文件对象
 */
export function getImgDataFromFile(file: File): Promise<ImageData> {
  const url = URL.createObjectURL(file);// 创建一个表示文件内容的临时URL
  const img = new Image();// 创建一个新的Image对象
  img.src = url;// 设置Image对象的src为临时URL
  return new Promise((resolve, reject) => {
    // 图片加载完成后的处理
    img.onload = function () {
      const canvas = document.createElement('canvas');// 创建canvas元素
      const ctx = canvas.getContext('2d');
      if (!ctx) {
        reject('canvas2d is null');
        return;
      }
      // 设置canvas的大小为图片的原始大小
      canvas.width = img.width;
      canvas.height = img.height;
      ctx.drawImage(img, 0, 0);// 将图片绘制到canvas上
      const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);// 从canvas获取ImageData
      URL.revokeObjectURL(url);// 释放临时URL
      resolve(imageData);
    };

  });
}
/**
 * 获取全景图面数据
 * @param file 
 */
export async function getFaceImgData(file: File, face: FaceEnum): Promise<ImageData> {
  try {
    const imgData = await getImgDataFromFile(file);
    return copyFaceData(imgData, face, 0, InterpolationEnum.Bilinear);
  } catch (e) {
    throw e;
  }
}
/**
 * 获取图片数据url
 * @param imgData 
 */
export async function getImgImgUrl(imgData: ImageData): Promise<string> {
  try {
    const blob = await imgDataToBlob(imgData, 'png');
    return URL.createObjectURL(blob);
  } catch (e) {
    throw e;
  }
}
/**
 * 获取文件六面url
 * @param file 图片文件对象
 */
export async function get6FaceImgUrl(file: File): Promise<Array<string>> {
  try {
    const [pxData, nxData, nyData, pyData, nzData, pzData] = await Promise.all([getFaceImgData(file, FaceEnum.PX), getFaceImgData(file, FaceEnum.NX),
    getFaceImgData(file, FaceEnum.NY), getFaceImgData(file, FaceEnum.PY), getFaceImgData(file, FaceEnum.NZ), getFaceImgData(file, FaceEnum.PZ)]);
    const [ pxUrl, nxUrl, nyUrl, pyUrl, nzUrl, pzUrl ] = await Promise.all([getImgImgUrl(pxData), getImgImgUrl(nxData), getImgImgUrl(nyData), getImgImgUrl(pyData), getImgImgUrl(nzData), getImgImgUrl(pzData)])
    return [ pxUrl, nxUrl, nyUrl, pyUrl, nzUrl, pzUrl ];
  } catch (e) {
    throw e;
  }
}
/**
 * 下载url文件
 * @param url 下载地址
 * @param name 文件名
 */
export function downloadUrlFile(url:string, name: string){
  const a = document.createElement('a');
  a.href = url;
  a.download = name;
  a.click();
}

结果展示

localhost_5173_panorama001.png

222222.png