生成Marzipano.js格式的VR切图JAVA版本

77 阅读6分钟
   @PostMapping("/upload2")
    public ResponseEntity<?> handleUpload(@RequestParam("file") MultipartFile file) {
        CorrectedMarzipanoGenerator generator = new CorrectedMarzipanoGenerator();
        try {
            // 1. 准备文件路径
            String uuid = UUID.randomUUID().toString();
            String extension = file.getOriginalFilename().substring(
                    file.getOriginalFilename().lastIndexOf(".")
            );
            Path uploadPath = Paths.get(tilesDir, "uploads", uuid + extension);
            Files.createDirectories(uploadPath.getParent());

            // 2. 保存原始文件
            file.transferTo(uploadPath.toFile());

            // 3. 生成瓦片
            BufferedImage image = ImageIO.read(uploadPath.toFile());
            String outputDir = Paths.get(tilesDir, "tiles", uuid).toString();

            new File(outputDir).mkdirs();
            generator.generate(image, outputDir);

            // 4. 返回结果
            Map<String, Object> response = new HashMap<>();
            response.put("status", "success");
            response.put("previewUrl", "/tiles/" + uuid + "/preview.jpg");
            response.put("levels", 4);
            return ResponseEntity.ok(response);

        } catch (Exception e) {
            Map<String, Object> error = new HashMap<>();
            error.put("status", "error");
            error.put("message", e.getMessage());
            return ResponseEntity.internalServerError().body(error);
        }
    }


import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;


/**
 * @Author: wanghaowen 542524570@163.com
 * @Date: 2025-04-02 16:41:14
 * @Description: 生成Marzipano格式的VR立方体贴图
 */
public class CorrectedMarzipanoGenerator {
    // 立方体面顺序定义:后、下、前、左、右、上
    private static final List<String> FACE_ORDER = Arrays.asList("b", "d", "f", "l", "r", "u");

    // 瓦片基础尺寸
    private static final int TILE_SIZE = 512;

    // 预览图尺寸
    private static final int PREVIEW_WIDTH = 256;
    private static final int PREVIEW_HEIGHT = 1536; // 6个面 x 256高度

    // 根据CPU核心数设置线程池大小
    private static final int THREAD_POOL_SIZE = Runtime.getRuntime().availableProcessors();

    /**
     * 主生成方法
     * @param equiImage 输入的等距柱状图
     * @param outputDir 输出目录
     */
    public void generate(BufferedImage equiImage, String outputDir) throws IOException {
        // 验证输入图像比例是否为2:1
        if (equiImage.getWidth() != 2 * equiImage.getHeight()) {
            throw new IllegalArgumentException("输入图片必须是2:1等距柱状图");
        }

        // 单线程生成预览图
        generatePreview(equiImage, outputDir + "/preview.jpg");

        // 计算立方体单面尺寸(等距柱状图高度的一半)
        int cubeSize = equiImage.getHeight() / 2;

        // 创建主线程池
        ExecutorService executor = Executors.newFixedThreadPool(THREAD_POOL_SIZE);

        try {
            // 提交各级别切图任务(1x1到8x8)
            executor.submit(() -> {
                try {
                    generateLevel(equiImage, outputDir, 1, cubeSize); // 级别1:1x1
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            });
            executor.submit(() -> {
                try {
                    generateLevel(equiImage, outputDir, 2, cubeSize / 2); // 级别2:2x2
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            });
            executor.submit(() -> {
                try {
                    generateLevel(equiImage, outputDir, 3, cubeSize / 4); // 级别3:4x4
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            });
            executor.submit(() -> {
                try {
                    generateLevel(equiImage, outputDir, 4, cubeSize / 8); // 级别4:8x8
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            });
        } finally {
            // 关闭线程池并设置超时
            executor.shutdown();
            try {
                executor.awaitTermination(1, TimeUnit.HOURS);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    }

    /**
     * 生成预览图
     * @param src 源图像
     * @param outputPath 输出路径
     */
    private void generatePreview(BufferedImage src, String outputPath) throws IOException {
        // 计算每个面的高度(总高度/6个面)
        int faceHeight = PREVIEW_HEIGHT / 6;

        // 创建预览图缓冲区
        BufferedImage preview = new BufferedImage(PREVIEW_WIDTH, PREVIEW_HEIGHT, BufferedImage.TYPE_INT_RGB);
        Graphics2D g = preview.createGraphics();

        // 设置黑色背景
        g.setColor(Color.BLACK);
        g.fillRect(0, 0, PREVIEW_WIDTH, PREVIEW_HEIGHT);

        // 遍历所有面并绘制
        for (int i = 0; i < FACE_ORDER.size(); i++) {
            String faceName = FACE_ORDER.get(i);
            // 提取并调整面大小
            BufferedImage face = extractAndResizeFace(src, faceName, PREVIEW_WIDTH, faceHeight);
            // 绘制到预览图
            g.drawImage(face, 0, i * faceHeight, null);
            // 添加面标签
            //addFaceLabel(g, faceName, 0, i * faceHeight, PREVIEW_WIDTH, faceHeight);
            face.flush();
        }

        // 保存预览图
        ImageIO.write(preview, "jpg", new File(outputPath));
        g.dispose();
    }

    /**
     * 生成指定级别的瓦片
     * @param src 源图像
     * @param outputDir 输出目录
     * @param level 级别(1-4)
     * @param faceSize 面基础尺寸
     */
    private void generateLevel(BufferedImage src, String outputDir, int level, int faceSize) throws IOException {
        // 计算每边的瓦片数量(2^(level-1))
        int tilesPerSide = (int) Math.pow(2, level - 1);
        // 计算调整后的面尺寸(瓦片数量 x 瓦片尺寸)
        int adjustedSize = tilesPerSide * TILE_SIZE;

        // 创建面处理线程池(使用一半CPU核心)
        ExecutorService faceExecutor = Executors.newFixedThreadPool(THREAD_POOL_SIZE / 2);

        try {
            // 遍历所有面
            for (String face : FACE_ORDER) {
                faceExecutor.submit(() -> {
                    try {
                        // 提取立方体面
                        BufferedImage faceImage = extractCubeFace(src, face, adjustedSize);
                        // 为面添加标签
                       // BufferedImage labeledFace = addLabelsToFace(faceImage, face, tilesPerSide);

                        // 创建瓦片保存线程池(使用另一半CPU核心)
                        ExecutorService tileExecutor = Executors.newFixedThreadPool(THREAD_POOL_SIZE / 2);
                        try {
                            // 遍历所有瓦片行
                            for (int ty = 0; ty < tilesPerSide; ty++) {
                                final int currentTy = ty;
                                // 提交瓦片保存任务
                                tileExecutor.submit(() -> {
                                    try {
                                        // 遍历所有瓦片列
                                        for (int tx = 0; tx < tilesPerSide; tx++) {
                                            // 保存瓦片
//                                            saveTile(
//                                                    labeledFace.getSubimage(
//                                                            tx * TILE_SIZE,
//                                                            currentTy * TILE_SIZE,
//                                                            TILE_SIZE,
//                                                            TILE_SIZE
//                                                    ),
//                                                    String.format("%s/%d/%s/%d/%d.jpg", outputDir, level, face, currentTy, tx)
//                                            );
                                            saveTile(
                                                    faceImage.getSubimage(
                                                            tx * TILE_SIZE,
                                                            currentTy * TILE_SIZE,
                                                            TILE_SIZE,
                                                            TILE_SIZE
                                                    ),
                                                    String.format("%s/%d/%s/%d/%d.jpg", outputDir, level, face, currentTy, tx)
                                            );
                                        }
                                    } catch (IOException e) {
                                        e.printStackTrace();
                                    }
                                });
                            }
                        } finally {
                            tileExecutor.shutdown();
                            tileExecutor.awaitTermination(30, TimeUnit.MINUTES);
                        }

                        // 释放资源
                        faceImage.flush();
                       // labeledFace.flush();
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                });
            }
        } finally {
            faceExecutor.shutdown();
            try {
                faceExecutor.awaitTermination(1, TimeUnit.HOURS);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }

    /**
     * 添加面标签(预览图用)
     * @param g 图形上下文
     * @param faceName 面名称
     * @param x 起始X坐标
     * @param y 起始Y坐标
     * @param width 宽度
     * @param height 高度
     */
    private void addFaceLabel(Graphics2D g, String faceName, int x, int y, int width, int height) {
        // 设置字体(大小为高度的1/3)
        Font font = new Font("Arial", Font.BOLD, height / 3);
        g.setFont(font);

        // 计算文字居中位置
        FontMetrics metrics = g.getFontMetrics();
        int textWidth = metrics.stringWidth(faceName);
        int textX = x + (width - textWidth) / 2;
        int textY = y + ((height - metrics.getHeight()) / 2) + metrics.getAscent();

        // 绘制黑色描边(增强可读性)
        g.setColor(Color.BLACK);
        g.drawString(faceName, textX - 1, textY - 1);
        g.drawString(faceName, textX + 1, textY - 1);
        g.drawString(faceName, textX - 1, textY + 1);
        g.drawString(faceName, textX + 1, textY + 1);

        // 绘制红色文字
        g.setColor(Color.RED);
        g.drawString(faceName, textX, textY);
    }

    /**
     * 为面图像添加瓦片标签
     * @param faceImage 面图像
     * @param faceName 面名称
     * @param tilesPerSide 每边瓦片数
     * @return 带标签的图像
     */
    private BufferedImage addLabelsToFace(BufferedImage faceImage, String faceName, int tilesPerSide) {
        // 创建带标签的图像缓冲区
        BufferedImage labeledFace = new BufferedImage(
                faceImage.getWidth(),
                faceImage.getHeight(),
                BufferedImage.TYPE_INT_RGB
        );
        Graphics2D g = labeledFace.createGraphics();
        // 绘制原始面图像
        g.drawImage(faceImage, 0, 0, null);

        // 遍历所有瓦片位置
        for (int ty = 0; ty < tilesPerSide; ty++) {
            for (int tx = 0; tx < tilesPerSide; tx++) {
                // 计算瓦片中心位置
                int centerX = tx * TILE_SIZE + TILE_SIZE/2;
                int centerY = ty * TILE_SIZE + TILE_SIZE/2;

                // 在瓦片中心添加标签(大小为瓦片的一半)
                addTileLabel(
                        g,
                        faceName,
                        centerX - TILE_SIZE/4,
                        centerY - TILE_SIZE/4,
                        TILE_SIZE/2,
                        TILE_SIZE/2
                );
            }
        }
        g.dispose();
        return labeledFace;
    }

    /**
     * 添加瓦片标签(比预览图标记小)
     */
    private void addTileLabel(Graphics2D g, String faceName, int x, int y, int width, int height) {
        // 设置字体(大小为高度的1/3)
        Font font = new Font("Arial", Font.BOLD, height / 3);
        g.setFont(font);

        // 计算文字居中位置
        FontMetrics metrics = g.getFontMetrics();
        int textWidth = metrics.stringWidth(faceName);
        int textX = x + (width - textWidth) / 2;
        int textY = y + ((height - metrics.getHeight()) / 2) + metrics.getAscent();

        // 黑色描边(增强可读性)
        g.setColor(Color.BLACK);
        g.drawString(faceName, textX - 1, textY - 1);
        g.drawString(faceName, textX + 1, textY - 1);
        g.drawString(faceName, textX - 1, textY + 1);
        g.drawString(faceName, textX + 1, textY + 1);

        // 红色文字
        g.setColor(Color.RED);
        g.drawString(faceName, textX, textY);
    }

    /**
     * 从等距柱状图提取立方体面
     * @param src 源图像
     * @param face 面名称
     * @param size 输出尺寸
     * @return 提取的面图像
     */
    private BufferedImage extractCubeFace(BufferedImage src, String face, int size) {
        BufferedImage faceImage = new BufferedImage(size, size, BufferedImage.TYPE_INT_RGB);

        // 遍历所有像素
        for (int y = 0; y < size; y++) {
            for (int x = 0; x < size; x++) {
                // 计算UV坐标
                double[] uv = getCorrectedUV(x, y, size, face);
                // 计算源图像坐标
                int srcX = (int) (uv[0] * (src.getWidth() - 1));
                int srcY = (int) (uv[1] * (src.getHeight() - 1));

                // 确保坐标不越界
                srcX = Math.max(0, Math.min(srcX, src.getWidth() - 1));
                srcY = Math.max(0, Math.min(srcY, src.getHeight() - 1));

                // 设置目标像素颜色
                faceImage.setRGB(x, y, src.getRGB(srcX, srcY));
            }
        }
        return faceImage;
    }

    /**
     * 计算修正后的UV坐标
     * @param x 像素X坐标
     * @param y 像素Y坐标
     * @param size 面尺寸
     * @param face 面名称
     * @return UV坐标数组
     */
    private double[] getCorrectedUV(int x, int y, int size, String face) {
        // 归一化坐标到[-1,1]范围
        double u = (2.0 * x / size) - 1;
        double v = (2.0 * y / size) - 1;

        // 根据面类型计算3D向量
        double[] vec;
        switch (face) {
            case "f": // 前
                vec = new double[]{u, -v, 1}; break;
            case "r": // 右
                vec = new double[]{1, -v, -u}; break;
            case "b": // 后
                vec = new double[]{-u, -v, -1}; break;
            case "l": // 左
                vec = new double[]{-1, -v, u}; break;
            case "u": // 上
                vec = new double[]{u, 1, v}; break;
            case "d": // 下
                vec = new double[]{u, -1, -v}; break;
            default:
                throw new IllegalArgumentException("无效面类型: " + face);
        }

        // 向量归一化
        double len = Math.sqrt(vec[0] * vec[0] + vec[1] * vec[1] + vec[2] * vec[2]);
        vec[0] /= len;
        vec[1] /= len;
        vec[2] /= len;

        // 转换为球面坐标
        double theta = Math.atan2(vec[0], vec[2]); // 水平角度
        double phi = Math.asin(vec[1]);           // 垂直角度

        // 转换为UV坐标并返回
        return new double[]{
                (theta + Math.PI) / (2 * Math.PI),  // U坐标
                (Math.PI / 2 - phi) / Math.PI       // V坐标(修正Y轴)
        };
    }

    /**
     * 提取并调整面大小
     */
    private BufferedImage extractAndResizeFace(BufferedImage src, String face, int width, int height) {
        // 计算源面尺寸(等距柱状图高度的一半)
        int srcSize = src.getHeight() / 2;
        // 提取面
        BufferedImage faceImage = extractCubeFace(src, face, srcSize);
        // 调整大小
        BufferedImage resized = resize(faceImage, width, height);
        faceImage.flush();
        return resized;
    }

    /**
     * 图像缩放
     */
    private BufferedImage resize(BufferedImage src, int width, int height) {
        BufferedImage resized = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
        Graphics2D g = resized.createGraphics();
        // 设置高质量缩放
        g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
        g.drawImage(src, 0, 0, width, height, null);
        g.dispose();
        return resized;
    }

    /**
     * 保存瓦片图像
     */
    private void saveTile(BufferedImage tile, String path) throws IOException {
        File outputFile = new File(path);
        // 创建父目录(如果不存在)
        File parent = outputFile.getParentFile();
        if (!parent.exists()) {
            parent.mkdirs();
        }

        // 使用缓冲流提高写入性能
        try (BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(outputFile))) {
            ImageIO.write(tile, "jpg", bos);
        }
    }
}