摸鱼笔记[5]-海康VisionMaster九点标定生成工具

摘要

基于java的VisionMaster的标定文件生成小工具.

声明

本文人类为第一作者, 龙虾为通讯作者. 本文有AI生成内容.

简介

九点标定简介

通过让相机拍摄(或运动平台移动至)9 个已知位置的特征点,建立像素坐标系与机械/物理坐标系之间的映射关系。

  1. 准备标定板或标定特征(圆点、十字、棋盘格等)
  2. 运动平台依次移动到 9 个预设位置
  3. 相机拍摄每个位置的图像,提取像素坐标 (uᵢ, vᵢ)
  4. 记录对应的物理坐标 (Xᵢ, Yᵢ)
  5. 计算变换矩阵(仿射/透视变换)
  6. 验证精度,补偿误差
import numpy as np
import cv2

# 9 个像素坐标 (u, v)
pixel_points = np.array([
    [100, 100], [400, 100], [700, 100],
    [700, 400], [400, 400], [100, 400],
    [100, 700], [400, 700], [700, 700]
], dtype=np.float32)

# 对应的 9 个物理坐标 (X, Y),单位 mm
physical_points = np.array([
    [0, 0], [50, 0], [100, 0],
    [100, 50], [50, 50], [0, 50],
    [0, 100], [50, 100], [100, 100]
], dtype=np.float32)

# 计算仿射变换矩阵 (使用全部 9 点做最小二乘)
matrix, _ = cv2.estimateAffine2D(pixel_points, physical_points)

# 像素 → 物理坐标转换
pixel = np.array([[400, 400]], dtype=np.float32)  # 中心点
physical = cv2.transform(np.array([pixel]), matrix)
print(f"像素 {pixel[0]} → 物理 {physical[0][0]}")

海康VisionMaster简介

[https://www.hikrobotics.com/cn/machinevision/visionmaster/]
VM算法开发平台是海康机器人自主开发的机器视觉软件,致力于为客户提供快速搭建视觉应用、解决视觉检测难题的算法工具, 能满足视觉定位、尺寸测量、缺陷检测以及信息识别等机器视觉应用。

<?xml version="1.0" encoding="UTF-8"?>
<CalibInfo>
    <CalibInputParam>
        <CalibParam ParamName="CreateCalibTime" DataType="string">
            <ParamValue>2026-06-02 20:42:50</ParamValue>
        </CalibParam>
        <CalibParam ParamName="CalibType" DataType="string">
            <ParamValue>NPointCalib</ParamValue>
        </CalibParam>
        <CalibParam ParamName="CameraMode" DataType="int">
            <ParamValue>CameraStatic</ParamValue>
        </CalibParam>
        <CalibParam ParamName="TransNum" DataType="int">
            <ParamValue>4</ParamValue>
        </CalibParam>
        <CalibParam ParamName="RotNum" DataType="int">
            <ParamValue>0</ParamValue>
        </CalibParam>
        <CalibParam ParamName="CalibErrStatus" DataType="int">
            <ParamValue>269496395</ParamValue>
        </CalibParam>
        <CalibParam ParamName="TransError" DataType="float">
            <ParamValue>946.91974</ParamValue>
        </CalibParam>
        <CalibParam ParamName="RotError" DataType="float">
            <ParamValue>-999</ParamValue>
        </CalibParam>
        <CalibParam ParamName="TransWorldError" DataType="float">
            <ParamValue>6.1743436</ParamValue>
        </CalibParam>
        <CalibParam ParamName="RotWorldError" DataType="float">
            <ParamValue>-999</ParamValue>
        </CalibParam>
        <CalibParam ParamName="PixelPrecisionX" DataType="float">
            <ParamValue>0.0058826082</ParamValue>
        </CalibParam>
        <CalibParam ParamName="PixelPrecisionY" DataType="float">
            <ParamValue>0.0097637726</ParamValue>
        </CalibParam>
        <CalibParam ParamName="PixelPrecision" DataType="float">
            <ParamValue>0.0065204506</ParamValue>
        </CalibParam>
        <CalibPointFListParam ParamName="ImagePointLst" DataType="CalibPointList">
            <PointF>
                <X>1945.932</X>
                <Y>3429.5669</Y>
                <R>0</R>
            </PointF>
            <PointF>
                <X>3814.395</X>
                <Y>3428.6741</Y>
                <R>0</R>
            </PointF>
            <PointF>
                <X>1510.348</X>
                <Y>2837.3669</Y>
                <R>0</R>
            </PointF>
            <PointF>
                <X>1291.3627</X>
                <Y>1465.9149</Y>
                <R>0</R>
            </PointF>
        </CalibPointFListParam>
        <CalibPointFListParam ParamName="WorldPointLst" DataType="CalibPointList">
            <PointF>
                <X>101.5</X>
                <Y>68.5</Y>
                <R>0</R>
            </PointF>
            <PointF>
                <X>112.5</X>
                <Y>68.5</Y>
                <R>0</R>
            </PointF>
            <PointF>
                <X>97</X>
                <Y>65</Y>
                <R>0</R>
            </PointF>
            <PointF>
                <X>116.5</X>
                <Y>65</Y>
                <R>0</R>
            </PointF>
        </CalibPointFListParam>
    </CalibInputParam>
    <CalibOutputParam>
        <CalibParam ParamName="RotDirectionState" DataType="int">
            <ParamValue>-999</ParamValue>
        </CalibParam>
        <CalibParam ParamName="IsRightCoorA" DataType="int">
            <ParamValue>1</ParamValue>
        </CalibParam>
        <PointF ParamName="RotCenterImagePoint" DataType="CalibPointF">
            <RotCenterImagePointX>0</RotCenterImagePointX>
            <RotCenterImagePointY>0</RotCenterImagePointY>
            <RotCenterImageR>-999</RotCenterImageR>
        </PointF>
        <PointF ParamName="RotCenterWorldPoint" DataType="CalibPointF">
            <RotCenterWorldPointX>0</RotCenterWorldPointX>
            <RotCenterWorldPointY>0</RotCenterWorldPointY>
            <RotCenterWorldR>-999</RotCenterWorldR>
        </PointF>
        <CalibFloatListParam ParamName="CalibMatrix" DataType="FloatList">
            <ParamValue>0.0058826082</ParamValue>
            <ParamValue>-0.0095997574</ParamValue>
            <ParamValue>122.97585</ParamValue>
            <ParamValue>8.523678e-007</ParamValue>
            <ParamValue>0.0017821094</ParamValue>
            <ParamValue>62.386482</ParamValue>
            <ParamValue>0</ParamValue>
            <ParamValue>0</ParamValue>
            <ParamValue>1</ParamValue>
        </CalibFloatListParam>
    </CalibOutputParam>
</CalibInfo>

工程

生成九点标定图片

from PIL import Image, ImageDraw
import os

# 画布尺寸
width, height = 800, 800
# 坐标系范围 (0,0) 到 (10,10)
x_range = 10
y_range = 10
# 圆点半径(像素)
radius = 60

# 9 个位置定义(x, y)在 0-10 坐标系中
positions = [
    (2, 2),    # 左上
    (5, 2),    # 中上
    (8, 2),    # 右上
    (8, 5),    # 右中
    (5, 5),    # 中心
    (2, 5),    # 左中
    (2, 8),    # 左下
    (5, 8),    # 中下
    (8, 8),    # 右下
]

# 创建输出目录
output_dir = "/mnt/agents/output"
os.makedirs(output_dir, exist_ok=True)

def coord_to_pixel(x, y, w, h, x_max, y_max):
    """将坐标系坐标转换为像素坐标"""
    px = int((x / x_max) * w)
    py = int((y / y_max) * h)
    return px, py

for i, (cx, cy) in enumerate(positions, 1):
    # 创建白色背景
    img = Image.new('RGB', (width, height), 'white')
    draw = ImageDraw.Draw(img)
    
    # 绘制坐标标记 (0,0) 左上角, (10,10) 右下角
    # 使用简单的文本标记
    try:
        # 尝试使用默认字体
        from PIL import ImageFont
        font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 40)
    except:
        font = ImageFont.load_default()
    
    # 绘制坐标文字
    draw.text((20, 20), "(0,0)", fill='black', font=font)
    draw.text((width - 180, height - 60), "(10,10)", fill='black', font=font)
    
    # 计算圆心像素坐标
    px, py = coord_to_pixel(cx, cy, width, height, x_range, y_range)
    
    # 绘制黑色圆点
    draw.ellipse([px - radius, py - radius, px + radius, py + radius], fill='black')
    
    # 保存图片
    filepath = os.path.join(output_dir, f"{i}.png")
    img.save(filepath, 'PNG')
    print(f"已生成: {filepath} -> 圆点坐标 ({cx}, {cy})")

print("\n全部 9 张图片生成完成!")

参考图片:

九点标定图片
2

生成xml标定文件的小工具

  • 技术栈: java8 + gradle + flatlaf

build.gradle

plugins {
    id 'java'
    id 'application'
    id 'com.github.johnrengelman.shadow' version '8.1.1'
}

group = 'com.hikvision'
version = '1.0.0'

java {
    sourceCompatibility = JavaVersion.VERSION_1_8
    targetCompatibility = JavaVersion.VERSION_1_8
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'com.formdev:flatlaf:3.5.4'
    implementation 'org.apache.poi:poi-ooxml:5.2.5'
    implementation 'org.ejml:ejml-simple:0.41'
}

application {
    mainClass = 'com.hikvision.calib.Main'
}

shadowJar {
    archiveClassifier = ''
    mergeServiceFiles()
}

jar {
    manifest {
        attributes(
            'Main-Class': 'com.hikvision.calib.Main'
        )
    }
}

Main.java

package com.hikvision.calib;

import com.formdev.flatlaf.FlatLightLaf;
import com.hikvision.calib.ui.MainFrame;

import javax.swing.*;

public class Main {
    public static void main(String[] args) {
        SwingUtilities.invokeLater(() -> {
            try {
                FlatLightLaf.setup();
                UIManager.setLookAndFeel(new FlatLightLaf());
            } catch (Exception e) {
                System.err.println("FlatLaf setup failed: " + e.getMessage());
            }

            MainFrame frame = new MainFrame();
            frame.setVisible(true);
        });
    }
}

model/CalibInfo.java

package com.hikvision.calib.model;

import java.util.List;

/**
 * 标定信息汇总(用于生成 XML)
 */
public class CalibInfo {
    private String calibType = "NPointCalib";
    private String cameraMode = "CameraStatic";
    private int rotNum = 0;
    private int calibErrStatus = 269496395;
    private double rotError = -999.0;
    private double rotWorldError = -999.0;
    private int rotDirectionState = -999;
    private int isRightCoorA = 1;
    private double[] rotCenterImage = new double[]{0, 0, -999};
    private double[] rotCenterWorld = new double[]{0, 0, -999};

    private List<CalibPoint> points;
    private CalibMatrix matrix;
    private double transError;
    private double pixelPrecisionX;
    private double pixelPrecisionY;
    private double pixelPrecision;

    public String getCalibType() {
        return calibType;
    }

    public void setCalibType(String calibType) {
        this.calibType = calibType;
    }

    public String getCameraMode() {
        return cameraMode;
    }

    public void setCameraMode(String cameraMode) {
        this.cameraMode = cameraMode;
    }

    public int getRotNum() {
        return rotNum;
    }

    public void setRotNum(int rotNum) {
        this.rotNum = rotNum;
    }

    public int getCalibErrStatus() {
        return calibErrStatus;
    }

    public void setCalibErrStatus(int calibErrStatus) {
        this.calibErrStatus = calibErrStatus;
    }

    public double getRotError() {
        return rotError;
    }

    public void setRotError(double rotError) {
        this.rotError = rotError;
    }

    public double getRotWorldError() {
        return rotWorldError;
    }

    public void setRotWorldError(double rotWorldError) {
        this.rotWorldError = rotWorldError;
    }

    public int getRotDirectionState() {
        return rotDirectionState;
    }

    public void setRotDirectionState(int rotDirectionState) {
        this.rotDirectionState = rotDirectionState;
    }

    public int getIsRightCoorA() {
        return isRightCoorA;
    }

    public void setIsRightCoorA(int isRightCoorA) {
        this.isRightCoorA = isRightCoorA;
    }

    public double[] getRotCenterImage() {
        return rotCenterImage;
    }

    public void setRotCenterImage(double[] rotCenterImage) {
        this.rotCenterImage = rotCenterImage;
    }

    public double[] getRotCenterWorld() {
        return rotCenterWorld;
    }

    public void setRotCenterWorld(double[] rotCenterWorld) {
        this.rotCenterWorld = rotCenterWorld;
    }

    public List<CalibPoint> getPoints() {
        return points;
    }

    public void setPoints(List<CalibPoint> points) {
        this.points = points;
    }

    public CalibMatrix getMatrix() {
        return matrix;
    }

    public void setMatrix(CalibMatrix matrix) {
        this.matrix = matrix;
    }

    public double getTransError() {
        return transError;
    }

    public void setTransError(double transError) {
        this.transError = transError;
    }

    public double getPixelPrecisionX() {
        return pixelPrecisionX;
    }

    public void setPixelPrecisionX(double pixelPrecisionX) {
        this.pixelPrecisionX = pixelPrecisionX;
    }

    public double getPixelPrecisionY() {
        return pixelPrecisionY;
    }

    public void setPixelPrecisionY(double pixelPrecisionY) {
        this.pixelPrecisionY = pixelPrecisionY;
    }

    public double getPixelPrecision() {
        return pixelPrecision;
    }

    public void setPixelPrecision(double pixelPrecision) {
        this.pixelPrecision = pixelPrecision;
    }
}

model/CalibMatrix.java

package com.hikvision.calib.model;

import java.util.Arrays;

/**
 * 3x3 标定变换矩阵 [a,b,c, d,e,f, 0,0,1]
 */
public class CalibMatrix {
    private final double[] values; // 9 elements, row-major

    public CalibMatrix(double[] values) {
        if (values == null || values.length != 9) {
            throw new IllegalArgumentException("Matrix must have exactly 9 elements");
        }
        this.values = Arrays.copyOf(values, 9);
    }

    public double[] getValues() {
        return Arrays.copyOf(values, 9);
    }

    public double get(int row, int col) {
        return values[row * 3 + col];
    }

    public double getA() { return values[0]; }
    public double getB() { return values[1]; }
    public double getC() { return values[2]; }
    public double getD() { return values[3]; }
    public double getE() { return values[4]; }
    public double getF() { return values[5]; }

    /**
     * 像素坐标 -> 世界坐标
     */
    public double[] imageToWorld(double px, double py) {
        double wx = values[0] * px + values[1] * py + values[2];
        double wy = values[3] * px + values[4] * py + values[5];
        return new double[]{wx, wy};
    }

    /**
     * 世界坐标 -> 像素坐标(解线性方程组)
     */
    public double[] worldToImage(double wx, double wy) {
        double a = values[0], b = values[1], c = values[2];
        double d = values[3], e = values[4], f = values[5];
        double det = a * e - b * d;
        if (Math.abs(det) < 1e-12) {
            return null; // singular
        }
        double rhsX = wx - c;
        double rhsY = wy - f;
        double px = (e * rhsX - b * rhsY) / det;
        double py = (-d * rhsX + a * rhsY) / det;
        return new double[]{px, py};
    }

    @Override
    public String toString() {
        return String.format("[%.6f, %.6f, %.6f]\n[%.6f, %.6f, %.6f]\n[0.0, 0.0, 1.0]",
                values[0], values[1], values[2], values[3], values[4], values[5]);
    }
}

model/CalibPoint.java

package com.hikvision.calib.model;

/**
 * 标定点:包含像素坐标和世界坐标
 */
public class CalibPoint {
    private double imageX;
    private double imageY;
    private double worldX;
    private double worldY;

    public CalibPoint(double imageX, double imageY, double worldX, double worldY) {
        this.imageX = imageX;
        this.imageY = imageY;
        this.worldX = worldX;
        this.worldY = worldY;
    }

    public double getImageX() {
        return imageX;
    }

    public void setImageX(double imageX) {
        this.imageX = imageX;
    }

    public double getImageY() {
        return imageY;
    }

    public void setImageY(double imageY) {
        this.imageY = imageY;
    }

    public double getWorldX() {
        return worldX;
    }

    public void setWorldX(double worldX) {
        this.worldX = worldX;
    }

    public double getWorldY() {
        return worldY;
    }

    public void setWorldY(double worldY) {
        this.worldY = worldY;
    }

    @Override
    public String toString() {
        return String.format("CalibPoint{image=(%.4f, %.4f), world=(%.4f, %.4f)}",
                imageX, imageY, worldX, worldY);
    }
}

ui/MainFrame.java

package com.hikvision.calib.ui;

import com.hikvision.calib.model.CalibInfo;
import com.hikvision.calib.model.CalibMatrix;
import com.hikvision.calib.model.CalibPoint;
import com.hikvision.calib.util.*;

import javax.swing.*;
import javax.swing.border.TitledBorder;
import javax.swing.filechooser.FileNameExtensionFilter;
import javax.swing.table.DefaultTableModel;
import java.awt.*;
import java.io.File;
import java.io.FileWriter;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;

/**
 * 主界面
 */
public class MainFrame extends JFrame {

    private final List<CalibPoint> points = new ArrayList<>();
    private CalibMatrix currentMatrix;

    // UI components
    private final JTextField xlsxPathField = new JTextField(45);
    private JTable table;
    private DefaultTableModel tableModel;

    // Error labels
    private final JLabel errMaxLabel = new JLabel("--");
    private final JLabel errMeanLabel = new JLabel("--");
    private final JLabel errRmseLabel = new JLabel("--");
    private final JLabel errStatusLabel = new JLabel("请先加载数据");

    // Param controls
    private final JComboBox<String> calibTypeCombo = new JComboBox<>(new String[]{"NPointCalib", "NPointCalib_Rot", "HandEyeCalib"});
    private final JComboBox<String> cameraModeCombo = new JComboBox<>(new String[]{"CameraStatic", "CameraMove"});
    private final JSpinner rotNumSpinner = new JSpinner(new SpinnerNumberModel(0, 0, 10, 1));
    private final JComboBox<Integer> isRightCoorCombo = new JComboBox<>(new Integer[]{0, 1});

    // Log
    private final JTextArea logArea = new JTextArea(8, 60);
    private final JLabel statusLabel = new JLabel("就绪");

    public MainFrame() {
        setTitle("海康相机标定XML生成器");
        setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        setSize(1000, 820);
        setLocationRelativeTo(null);
        setMinimumSize(new Dimension(900, 700));

        initUI();
    }

    private void initUI() {
        setLayout(new BorderLayout(5, 5));

        // Top: file selection
        add(createFilePanel(), BorderLayout.NORTH);

        // Center: table + errors + params + log
        JPanel center = new JPanel(new BorderLayout(5, 5));
        center.add(createTablePanel(), BorderLayout.CENTER);

        JPanel southCenter = new JPanel();
        southCenter.setLayout(new BoxLayout(southCenter, BoxLayout.Y_AXIS));
        southCenter.add(createErrorPanel());
        southCenter.add(createButtonPanel());
        southCenter.add(createParamPanel());
        southCenter.add(createLogPanel());
        center.add(southCenter, BorderLayout.SOUTH);

        add(center, BorderLayout.CENTER);

        // Status bar
        statusLabel.setBorder(BorderFactory.createLoweredBevelBorder());
        add(statusLabel, BorderLayout.SOUTH);
    }

    private JPanel createFilePanel() {
        JPanel panel = new JPanel(new BorderLayout(5, 5));
        panel.setBorder(new TitledBorder("Excel 标定数据文件"));
        panel.add(xlsxPathField, BorderLayout.CENTER);

        JPanel btnPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 5, 0));
        JButton browseBtn = new JButton("浏览...");
        JButton loadBtn = new JButton("加载数据");
        JButton templateBtn = new JButton("创建模板xlsx");
        btnPanel.add(browseBtn);
        btnPanel.add(loadBtn);
        btnPanel.add(templateBtn);
        panel.add(btnPanel, BorderLayout.EAST);

        browseBtn.addActionListener(e -> browseXlsx());
        loadBtn.addActionListener(e -> loadXlsx());
        templateBtn.addActionListener(e -> createTemplate());

        return panel;
    }

    private JPanel createTablePanel() {
        JPanel panel = new JPanel(new BorderLayout());
        panel.setBorder(new TitledBorder("标定点数据"));

        String[] cols = {"序号", "像素X (ImageX)", "像素Y (ImageY)", "世界X (WorldX)", "世界Y (WorldY)", "误差(世界单位)"};
        tableModel = new DefaultTableModel(cols, 0) {
            @Override
            public boolean isCellEditable(int row, int column) {
                return false;
            }
        };
        table = new JTable(tableModel);
        table.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
        table.setAutoResizeMode(JTable.AUTO_RESIZE_ALL_COLUMNS);

        // Column widths
        int[] widths = {50, 120, 120, 120, 120, 120};
        for (int i = 0; i < widths.length; i++) {
            table.getColumnModel().getColumn(i).setPreferredWidth(widths[i]);
        }

        panel.add(new JScrollPane(table), BorderLayout.CENTER);
        return panel;
    }

    private JPanel createErrorPanel() {
        JPanel panel = new JPanel(new FlowLayout(FlowLayout.LEFT, 10, 5));
        panel.setBorder(new TitledBorder("标定误差统计(世界坐标单位尺度)"));

        errMaxLabel.setFont(new Font("Consolas", Font.BOLD, 12));
        errMaxLabel.setForeground(new Color(0xc0, 0x00, 0x00));
        errMeanLabel.setFont(new Font("Consolas", Font.BOLD, 12));
        errMeanLabel.setForeground(new Color(0x00, 0x60, 0xc0));
        errRmseLabel.setFont(new Font("Consolas", Font.BOLD, 12));
        errRmseLabel.setForeground(new Color(0x00, 0x60, 0xc0));
        errStatusLabel.setFont(new Font("Consolas", Font.PLAIN, 11));

        panel.add(new JLabel("最大误差:"));
        panel.add(errMaxLabel);
        panel.add(Box.createHorizontalStrut(10));
        panel.add(new JLabel("平均误差:"));
        panel.add(errMeanLabel);
        panel.add(Box.createHorizontalStrut(10));
        panel.add(new JLabel("RMSE:"));
        panel.add(errRmseLabel);
        panel.add(Box.createHorizontalStrut(10));
        panel.add(new JLabel("状态:"));
        panel.add(errStatusLabel);

        return panel;
    }

    private JPanel createButtonPanel() {
        JPanel panel = new JPanel(new BorderLayout());
        panel.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));

        JPanel left = new JPanel(new FlowLayout(FlowLayout.LEFT, 5, 0));
        JButton addBtn = new JButton("➕ 添加行");
        JButton delBtn = new JButton("➖ 删除选中行");
        JButton clearBtn = new JButton("📋 清空全部");
        JButton testBtn = new JButton("🔍 坐标转换测试");
        left.add(addBtn);
        left.add(delBtn);
        left.add(clearBtn);
        left.add(testBtn);

        JPanel right = new JPanel(new FlowLayout(FlowLayout.RIGHT, 5, 0));
        JButton genBtn = new JButton("💾 生成XML");
        right.add(genBtn);

        panel.add(left, BorderLayout.WEST);
        panel.add(right, BorderLayout.EAST);

        addBtn.addActionListener(e -> addRow());
        delBtn.addActionListener(e -> deleteRows());
        clearBtn.addActionListener(e -> clearAll());
        testBtn.addActionListener(e -> openTransformTest());
        genBtn.addActionListener(e -> generateXml());

        return panel;
    }

    private JPanel createParamPanel() {
        JPanel panel = new JPanel(new GridBagLayout());
        panel.setBorder(new TitledBorder("标定参数(可选)"));
        GridBagConstraints gbc = new GridBagConstraints();
        gbc.insets = new Insets(3, 8, 3, 8);
        gbc.anchor = GridBagConstraints.WEST;

        gbc.gridx = 0; gbc.gridy = 0;
        panel.add(new JLabel("标定类型:"), gbc);
        gbc.gridx = 1;
        calibTypeCombo.setPreferredSize(new Dimension(160, 24));
        panel.add(calibTypeCombo, gbc);

        gbc.gridx = 2;
        panel.add(new JLabel("相机模式:"), gbc);
        gbc.gridx = 3;
        cameraModeCombo.setPreferredSize(new Dimension(160, 24));
        panel.add(cameraModeCombo, gbc);

        gbc.gridx = 0; gbc.gridy = 1;
        panel.add(new JLabel("旋转点数:"), gbc);
        gbc.gridx = 1;
        rotNumSpinner.setPreferredSize(new Dimension(80, 24));
        panel.add(rotNumSpinner, gbc);

        gbc.gridx = 2;
        panel.add(new JLabel("右手坐标系:"), gbc);
        gbc.gridx = 3;
        isRightCoorCombo.setSelectedIndex(1);
        isRightCoorCombo.setPreferredSize(new Dimension(80, 24));
        panel.add(isRightCoorCombo, gbc);

        return panel;
    }

    private JPanel createLogPanel() {
        JPanel panel = new JPanel(new BorderLayout());
        panel.setBorder(new TitledBorder("日志 / XML预览"));
        logArea.setLineWrap(true);
        logArea.setWrapStyleWord(true);
        logArea.setEditable(false);
        panel.add(new JScrollPane(logArea), BorderLayout.CENTER);
        return panel;
    }

    // ==================== Actions ====================

    private void log(String msg) {
        String time = LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"));
        logArea.append("[" + time + "] " + msg + "\n");
        logArea.setCaretPosition(logArea.getDocument().getLength());
    }

    private void browseXlsx() {
        JFileChooser chooser = new JFileChooser();
        chooser.setDialogTitle("选择标定数据Excel文件");
        chooser.setFileFilter(new FileNameExtensionFilter("Excel 文件", "xlsx"));
        if (chooser.showOpenDialog(this) == JFileChooser.APPROVE_OPTION) {
            xlsxPathField.setText(chooser.getSelectedFile().getAbsolutePath());
        }
    }

    private void loadXlsx() {
        String path = xlsxPathField.getText().trim();
        if (path.isEmpty()) {
            JOptionPane.showMessageDialog(this, "请先选择一个有效的xlsx文件", "提示", JOptionPane.WARNING_MESSAGE);
            return;
        }
        File file = new File(path);
        if (!file.exists()) {
            JOptionPane.showMessageDialog(this, "文件不存在", "提示", JOptionPane.WARNING_MESSAGE);
            return;
        }
        try {
            List<CalibPoint> loaded = ExcelParser.parse(file);
            points.clear();
            points.addAll(loaded);
            updateErrorDisplay();
            log("成功加载 " + loaded.size() + " 个标定点,来自: " + file.getName());
            statusLabel.setText("已加载 " + loaded.size() + " 个点");
        } catch (Exception ex) {
            JOptionPane.showMessageDialog(this, "加载失败: " + ex.getMessage(), "加载失败", JOptionPane.ERROR_MESSAGE);
            log("加载失败: " + ex.getMessage());
        }
    }

    private void createTemplate() {
        JFileChooser chooser = new JFileChooser();
        chooser.setDialogTitle("保存模板Excel");
        chooser.setFileFilter(new FileNameExtensionFilter("Excel 文件", "xlsx"));
        if (chooser.showSaveDialog(this) != JFileChooser.APPROVE_OPTION) return;

        File file = chooser.getSelectedFile();
        String path = file.getAbsolutePath();
        if (!path.toLowerCase().endsWith(".xlsx")) {
            file = new File(path + ".xlsx");
        }
        try {
            ExcelTemplateWriter.write(file);
            log("模板已保存: " + file.getAbsolutePath());
            JOptionPane.showMessageDialog(this, "模板已保存到:\n" + file.getAbsolutePath(), "完成", JOptionPane.INFORMATION_MESSAGE);
        } catch (Exception ex) {
            JOptionPane.showMessageDialog(this, "错误: " + ex.getMessage(), "错误", JOptionPane.ERROR_MESSAGE);
        }
    }

    private void addRow() {
        AddPointDialog dlg = new AddPointDialog(this);
        dlg.setVisible(true);
        CalibPoint p = dlg.getResult();
        if (p != null) {
            points.add(p);
            updateErrorDisplay();
            log(String.format("手动添加点: Image=(%.3f,%.3f), World=(%.3f,%.3f)",
                    p.getImageX(), p.getImageY(), p.getWorldX(), p.getWorldY()));
        }
    }

    private void deleteRows() {
        int[] rows = table.getSelectedRows();
        if (rows.length == 0) {
            JOptionPane.showMessageDialog(this, "请先选中要删除的行", "提示", JOptionPane.WARNING_MESSAGE);
            return;
        }
        // Delete from largest index to smallest
        for (int i = rows.length - 1; i >= 0; i--) {
            points.remove(rows[i]);
        }
        updateErrorDisplay();
        log("删除了 " + rows.length + " 行");
    }

    private void clearAll() {
        if (JOptionPane.showConfirmDialog(this, "确定清空所有数据?", "确认", JOptionPane.YES_NO_OPTION) == JOptionPane.YES_OPTION) {
            points.clear();
            refreshTable();
            updateErrorDisplay();
            log("已清空所有数据");
        }
    }

    private void openTransformTest() {
        TransformTestDialog dlg = new TransformTestDialog(this, currentMatrix);
        dlg.setVisible(true);
    }

    private void generateXml() {
        if (points.size() < 3) {
            JOptionPane.showMessageDialog(this, "至少需要3个标定点才能生成XML", "数据不足", JOptionPane.WARNING_MESSAGE);
            return;
        }
        JFileChooser chooser = new JFileChooser();
        chooser.setDialogTitle("保存标定XML文件");
        chooser.setFileFilter(new FileNameExtensionFilter("XML 文件", "xml"));
        if (chooser.showSaveDialog(this) != JFileChooser.APPROVE_OPTION) return;

        File file = chooser.getSelectedFile();
        String path = file.getAbsolutePath();
        if (!path.toLowerCase().endsWith(".xml")) {
            file = new File(path + ".xml");
        }

        try {
            CalibInfo info = new CalibInfo();
            info.setPoints(points);
            info.setCalibType((String) calibTypeCombo.getSelectedItem());
            info.setCameraMode((String) cameraModeCombo.getSelectedItem());
            info.setRotNum((Integer) rotNumSpinner.getValue());
            info.setIsRightCoorA((Integer) isRightCoorCombo.getSelectedItem());

            CalibMatrix matrix = MathUtils.computeHomographyMatrix(points);
            info.setMatrix(matrix);
            info.setTransError(MathUtils.computeTransError(points, matrix));

            double[] pix = MathUtils.computePixelPrecision(points);
            info.setPixelPrecisionX(pix[0]);
            info.setPixelPrecisionY(pix[1]);
            info.setPixelPrecision(pix[2]);

            String xml = XmlGenerator.generate(info);

            try (FileWriter fw = new FileWriter(file)) {
                fw.write(xml);
            }

            log("XML已保存: " + file.getAbsolutePath());
            // Preview
            String preview = xml.length() > 1500 ? xml.substring(0, 1500) + "\n..." : xml;
            logArea.append("\n========== XML 预览 ==========\n");
            logArea.append(preview);
            logArea.append("\n==============================\n\n");
            logArea.setCaretPosition(logArea.getDocument().getLength());

            JOptionPane.showMessageDialog(this, "标定XML已保存到:\n" + file.getAbsolutePath(), "完成", JOptionPane.INFORMATION_MESSAGE);
            statusLabel.setText("已生成XML: " + file.getName());
        } catch (Exception ex) {
            JOptionPane.showMessageDialog(this, "生成失败: " + ex.getMessage(), "生成失败", JOptionPane.ERROR_MESSAGE);
            log("生成失败: " + ex.getMessage());
        }
    }

    // ==================== Table & Error Update ====================

    private void refreshTable() {
        tableModel.setRowCount(0);
        for (int i = 0; i < points.size(); i++) {
            CalibPoint p = points.get(i);
            tableModel.addRow(new Object[]{
                    i + 1,
                    String.format("%.4f", p.getImageX()),
                    String.format("%.4f", p.getImageY()),
                    String.format("%.4f", p.getWorldX()),
                    String.format("%.4f", p.getWorldY()),
                    "--"
            });
        }
    }

    private void updateErrorDisplay() {
        if (points.size() < 3) {
            errMaxLabel.setText("--");
            errMeanLabel.setText("--");
            errRmseLabel.setText("--");
            errStatusLabel.setText("数据不足");
            currentMatrix = null;
            refreshTable();
            return;
        }

        try {
            CalibMatrix matrix = MathUtils.computeHomographyMatrix(points);
            currentMatrix = matrix;
            MathUtils.ErrorResult err = MathUtils.computeWorldUnitErrors(points, matrix);

            errMaxLabel.setText(String.format("%.6f", err.maxError));
            errMeanLabel.setText(String.format("%.6f", err.meanError));
            errRmseLabel.setText(String.format("%.6f", err.rmse));

            // Update table with errors
            tableModel.setRowCount(0);
            for (int i = 0; i < points.size(); i++) {
                CalibPoint p = points.get(i);
                tableModel.addRow(new Object[]{
                        i + 1,
                        String.format("%.4f", p.getImageX()),
                        String.format("%.4f", p.getImageY()),
                        String.format("%.4f", p.getWorldX()),
                        String.format("%.4f", p.getWorldY()),
                        String.format("%.6f", err.errors.get(i))
                });
            }

            String status = String.format("已计算 %d 个点 | 最大误差: %.6f", points.size(), err.maxError);
            errStatusLabel.setText(status);
            log(String.format("误差统计: Max=%.6f, Mean=%.6f, RMSE=%.6f", err.maxError, err.meanError, err.rmse));
        } catch (Exception ex) {
            errMaxLabel.setText("--");
            errMeanLabel.setText("--");
            errRmseLabel.setText("--");
            errStatusLabel.setText("计算失败: " + ex.getMessage());
            currentMatrix = null;
            refreshTable();
        }
    }
}

ui/AddPointDialog.java

package com.hikvision.calib.ui;

import com.hikvision.calib.model.CalibPoint;

import javax.swing.*;
import java.awt.*;

/**
 * 添加标定点对话框
 */
public class AddPointDialog extends JDialog {

    private CalibPoint result;

    private final JTextField imageXField = new JTextField(12);
    private final JTextField imageYField = new JTextField(12);
    private final JTextField worldXField = new JTextField(12);
    private final JTextField worldYField = new JTextField(12);

    public AddPointDialog(JFrame parent) {
        super(parent, "添加标定点", true);
        initUI();
        pack();
        setLocationRelativeTo(parent);
    }

    private void initUI() {
        JPanel panel = new JPanel(new GridBagLayout());
        GridBagConstraints gbc = new GridBagConstraints();
        gbc.insets = new Insets(5, 5, 5, 5);
        gbc.anchor = GridBagConstraints.EAST;

        gbc.gridx = 0; gbc.gridy = 0;
        panel.add(new JLabel("像素X:"), gbc);
        gbc.gridx = 1;
        panel.add(imageXField, gbc);

        gbc.gridx = 0; gbc.gridy = 1;
        panel.add(new JLabel("像素Y:"), gbc);
        gbc.gridx = 1;
        panel.add(imageYField, gbc);

        gbc.gridx = 0; gbc.gridy = 2;
        panel.add(new JLabel("世界X:"), gbc);
        gbc.gridx = 1;
        panel.add(worldXField, gbc);

        gbc.gridx = 0; gbc.gridy = 3;
        panel.add(new JLabel("世界Y:"), gbc);
        gbc.gridx = 1;
        panel.add(worldYField, gbc);

        JPanel btnPanel = new JPanel();
        JButton okBtn = new JButton("确定");
        JButton cancelBtn = new JButton("取消");
        btnPanel.add(okBtn);
        btnPanel.add(cancelBtn);

        okBtn.addActionListener(e -> onOk());
        cancelBtn.addActionListener(e -> dispose());

        setLayout(new BorderLayout());
        add(panel, BorderLayout.CENTER);
        add(btnPanel, BorderLayout.SOUTH);

        getRootPane().setDefaultButton(okBtn);
    }

    private void onOk() {
        try {
            double ix = Double.parseDouble(imageXField.getText().trim());
            double iy = Double.parseDouble(imageYField.getText().trim());
            double wx = Double.parseDouble(worldXField.getText().trim());
            double wy = Double.parseDouble(worldYField.getText().trim());
            result = new CalibPoint(ix, iy, wx, wy);
            dispose();
        } catch (NumberFormatException ex) {
            JOptionPane.showMessageDialog(this, "请输入有效的数字", "输入错误", JOptionPane.ERROR_MESSAGE);
        }
    }

    public CalibPoint getResult() {
        return result;
    }
}

ui/TransformTestDialog.java

package com.hikvision.calib.ui;

import com.hikvision.calib.model.CalibMatrix;
import com.hikvision.calib.util.XmlParser;

import javax.swing.*;
import javax.swing.border.TitledBorder;
import java.awt.*;
import java.io.File;

/**
 * 坐标转换测试对话框
 */
public class TransformTestDialog extends JDialog {

    private CalibMatrix matrix;

    private JLabel matLabel;
    private final JTextField xmlPathField = new JTextField(35);

    // Pixel -> World
    private final JTextField p2wPx = new JTextField(10);
    private final JTextField p2wPy = new JTextField(10);
    private final JTextField p2wWx = new JTextField(10);
    private final JTextField p2wWy = new JTextField(10);

    // World -> Pixel
    private final JTextField w2pWx = new JTextField(10);
    private final JTextField w2pWy = new JTextField(10);
    private final JTextField w2pPx = new JTextField(10);
    private final JTextField w2pPy = new JTextField(10);

    // Batch
    private final JRadioButton batchP2w = new JRadioButton("像素→世界", true);
    private final JRadioButton batchW2p = new JRadioButton("世界→像素");
    private final JTextArea batchInput = new JTextArea(6, 20);
    private final JTextArea batchOutput = new JTextArea(6, 20);

    public TransformTestDialog(JFrame parent, CalibMatrix matrix) {
        super(parent, "像素点与世界点互转计算测试", true);
        this.matrix = matrix;

        setSize(600, 580);
        setLocationRelativeTo(parent);
        initUI();
        updateMatrixDisplay();
    }

    private void initUI() {
        JPanel content = new JPanel(new BorderLayout(10, 10));
        content.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));

        // XML load panel
        JPanel xmlPanel = new JPanel(new BorderLayout(5, 5));
        xmlPanel.setBorder(new TitledBorder("从标定XML文件加载矩阵"));
        xmlPanel.add(xmlPathField, BorderLayout.CENTER);
        JPanel xmlBtnPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 5, 0));
        JButton browseBtn = new JButton("浏览...");
        JButton loadBtn = new JButton("加载");
        xmlBtnPanel.add(browseBtn);
        xmlBtnPanel.add(loadBtn);
        xmlPanel.add(xmlBtnPanel, BorderLayout.EAST);

        browseBtn.addActionListener(e -> browseXml());
        loadBtn.addActionListener(e -> loadXml());

        // Matrix display
        JPanel matPanel = new JPanel(new BorderLayout());
        matPanel.setBorder(new TitledBorder("当前标定矩阵"));
        matLabel = new JLabel("暂无矩阵");
        matLabel.setFont(new Font("Consolas", Font.PLAIN, 12));
        matPanel.add(matLabel, BorderLayout.CENTER);

        // P2W panel
        JPanel p2wPanel = createP2WPanel();

        // W2P panel
        JPanel w2pPanel = createW2PPanel();

        // Batch panel
        JPanel batchPanel = createBatchPanel();

        // Close button
        JButton closeBtn = new JButton("关闭");
        closeBtn.addActionListener(e -> dispose());
        JPanel closePanel = new JPanel(new FlowLayout(FlowLayout.CENTER));
        closePanel.add(closeBtn);

        // Assembly
        JPanel centerPanel = new JPanel();
        centerPanel.setLayout(new BoxLayout(centerPanel, BoxLayout.Y_AXIS));
        centerPanel.add(xmlPanel);
        centerPanel.add(matPanel);
        centerPanel.add(p2wPanel);
        centerPanel.add(w2pPanel);
        centerPanel.add(batchPanel);

        content.add(centerPanel, BorderLayout.CENTER);
        content.add(closePanel, BorderLayout.SOUTH);

        setContentPane(content);
    }

    private JPanel createP2WPanel() {
        JPanel panel = new JPanel(new GridBagLayout());
        panel.setBorder(new TitledBorder("像素坐标 → 世界坐标"));
        GridBagConstraints gbc = new GridBagConstraints();
        gbc.insets = new Insets(3, 5, 3, 5);
        gbc.anchor = GridBagConstraints.EAST;

        gbc.gridx = 0; gbc.gridy = 0;
        panel.add(new JLabel("像素X:"), gbc);
        gbc.gridx = 1;
        panel.add(p2wPx, gbc);
        gbc.gridx = 2;
        panel.add(new JLabel("像素Y:"), gbc);
        gbc.gridx = 3;
        panel.add(p2wPy, gbc);
        gbc.gridx = 4;
        JButton btn = new JButton("转换 →");
        panel.add(btn, gbc);

        gbc.gridx = 0; gbc.gridy = 1;
        panel.add(new JLabel("世界X:"), gbc);
        gbc.gridx = 1;
        p2wWx.setEditable(false);
        panel.add(p2wWx, gbc);
        gbc.gridx = 2;
        panel.add(new JLabel("世界Y:"), gbc);
        gbc.gridx = 3;
        p2wWy.setEditable(false);
        panel.add(p2wWy, gbc);

        btn.addActionListener(e -> doP2W());
        return panel;
    }

    private JPanel createW2PPanel() {
        JPanel panel = new JPanel(new GridBagLayout());
        panel.setBorder(new TitledBorder("世界坐标 → 像素坐标"));
        GridBagConstraints gbc = new GridBagConstraints();
        gbc.insets = new Insets(3, 5, 3, 5);
        gbc.anchor = GridBagConstraints.EAST;

        gbc.gridx = 0; gbc.gridy = 0;
        panel.add(new JLabel("世界X:"), gbc);
        gbc.gridx = 1;
        panel.add(w2pWx, gbc);
        gbc.gridx = 2;
        panel.add(new JLabel("世界Y:"), gbc);
        gbc.gridx = 3;
        panel.add(w2pWy, gbc);
        gbc.gridx = 4;
        JButton btn = new JButton("转换 →");
        panel.add(btn, gbc);

        gbc.gridx = 0; gbc.gridy = 1;
        panel.add(new JLabel("像素X:"), gbc);
        gbc.gridx = 1;
        w2pPx.setEditable(false);
        panel.add(w2pPx, gbc);
        gbc.gridx = 2;
        panel.add(new JLabel("像素Y:"), gbc);
        gbc.gridx = 3;
        w2pPy.setEditable(false);
        panel.add(w2pPy, gbc);

        btn.addActionListener(e -> doW2P());
        return panel;
    }

    private JPanel createBatchPanel() {
        JPanel panel = new JPanel(new BorderLayout(5, 5));
        panel.setBorder(new TitledBorder("批量测试(输入坐标,每行一个 x,y)"));

        JPanel top = new JPanel(new FlowLayout(FlowLayout.LEFT));
        ButtonGroup bg = new ButtonGroup();
        bg.add(batchP2w);
        bg.add(batchW2p);
        top.add(batchP2w);
        top.add(batchW2p);
        JButton batchBtn = new JButton("批量转换");
        top.add(batchBtn);
        panel.add(top, BorderLayout.NORTH);

        JPanel textPanel = new JPanel(new GridLayout(1, 2, 5, 5));
        batchInput.setLineWrap(true);
        batchOutput.setLineWrap(true);
        batchOutput.setEditable(false);
        textPanel.add(new JScrollPane(batchInput));
        textPanel.add(new JScrollPane(batchOutput));
        panel.add(textPanel, BorderLayout.CENTER);

        batchBtn.addActionListener(e -> doBatch());
        return panel;
    }

    private void updateMatrixDisplay() {
        if (matrix == null) {
            matLabel.setText("暂无矩阵");
        } else {
            matLabel.setText("<html><pre>" + matrix.toString().replace("\n", "<br>") + "</pre></html>");
        }
    }

    private void browseXml() {
        JFileChooser chooser = new JFileChooser();
        chooser.setDialogTitle("选择标定XML文件");
        chooser.setFileFilter(new javax.swing.filechooser.FileNameExtensionFilter("XML 文件", "xml"));
        if (chooser.showOpenDialog(this) == JFileChooser.APPROVE_OPTION) {
            xmlPathField.setText(chooser.getSelectedFile().getAbsolutePath());
        }
    }

    private void loadXml() {
        String path = xmlPathField.getText().trim();
        if (path.isEmpty()) {
            JOptionPane.showMessageDialog(this, "请先选择一个有效的XML文件", "提示", JOptionPane.WARNING_MESSAGE);
            return;
        }
        File file = new File(path);
        if (!file.exists()) {
            JOptionPane.showMessageDialog(this, "文件不存在", "提示", JOptionPane.WARNING_MESSAGE);
            return;
        }
        try {
            XmlParser.ParseResult result = XmlParser.parse(file);
            this.matrix = result.matrix;
            updateMatrixDisplay();
            String calibType = result.info.getOrDefault("CalibType", "未知");
            String transNum = result.info.getOrDefault("TransNum", "未知");
            String transErr = result.info.getOrDefault("TransError", "未知");
            String msg = String.format("已加载XML矩阵: %s\n类型=%s | 点数=%s | TransError=%s",
                    file.getName(), calibType, transNum, transErr);
            JOptionPane.showMessageDialog(this, msg, "加载成功", JOptionPane.INFORMATION_MESSAGE);
        } catch (Exception ex) {
            JOptionPane.showMessageDialog(this, "加载失败: " + ex.getMessage(), "错误", JOptionPane.ERROR_MESSAGE);
        }
    }

    private boolean checkMatrix() {
        if (matrix == null) {
            JOptionPane.showMessageDialog(this, "请先加载标定矩阵(从主界面计算或从XML文件读取)", "提示", JOptionPane.WARNING_MESSAGE);
            return false;
        }
        return true;
    }

    private void doP2W() {
        if (!checkMatrix()) return;
        try {
            double px = Double.parseDouble(p2wPx.getText().trim());
            double py = Double.parseDouble(p2wPy.getText().trim());
            double[] result = matrix.imageToWorld(px, py);
            p2wWx.setText(String.format("%.6f", result[0]));
            p2wWy.setText(String.format("%.6f", result[1]));
        } catch (NumberFormatException ex) {
            JOptionPane.showMessageDialog(this, "请输入有效的数字", "输入错误", JOptionPane.ERROR_MESSAGE);
        }
    }

    private void doW2P() {
        if (!checkMatrix()) return;
        try {
            double wx = Double.parseDouble(w2pWx.getText().trim());
            double wy = Double.parseDouble(w2pWy.getText().trim());
            double[] result = matrix.worldToImage(wx, wy);
            if (result == null) {
                JOptionPane.showMessageDialog(this, "矩阵不可逆,无法转换", "错误", JOptionPane.ERROR_MESSAGE);
                return;
            }
            w2pPx.setText(String.format("%.6f", result[0]));
            w2pPy.setText(String.format("%.6f", result[1]));
        } catch (NumberFormatException ex) {
            JOptionPane.showMessageDialog(this, "请输入有效的数字", "输入错误", JOptionPane.ERROR_MESSAGE);
        }
    }

    private void doBatch() {
        if (!checkMatrix()) return;
        String text = batchInput.getText().trim();
        if (text.isEmpty()) return;

        StringBuilder sb = new StringBuilder();
        boolean isP2w = batchP2w.isSelected();

        for (String line : text.split("\n")) {
            line = line.trim();
            if (line.isEmpty()) continue;
            String[] parts = line.replace(",", ",").split(",");
            if (parts.length < 2) {
                sb.append("格式错误: ").append(line).append("\n");
                continue;
            }
            try {
                double x = Double.parseDouble(parts[0].trim());
                double y = Double.parseDouble(parts[1].trim());
                if (isP2w) {
                    double[] r = matrix.imageToWorld(x, y);
                    sb.append(String.format("%.3f,%.3f → %.6f,%.6f\n", x, y, r[0], r[1]));
                } else {
                    double[] r = matrix.worldToImage(x, y);
                    if (r == null) {
                        sb.append(String.format("%.3f,%.3f → 不可逆\n", x, y));
                    } else {
                        sb.append(String.format("%.3f,%.3f → %.6f,%.6f\n", x, y, r[0], r[1]));
                    }
                }
            } catch (NumberFormatException ex) {
                sb.append("格式错误: ").append(line).append("\n");
            }
        }
        batchOutput.setText(sb.toString());
    }
}

util/ExcelParser.java

package com.hikvision.calib.util;

import com.hikvision.calib.model.CalibPoint;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;

import java.io.File;
import java.io.FileInputStream;
import java.util.ArrayList;
import java.util.List;

/**
 * 解析 Excel 标定数据文件
 */
public class ExcelParser {

    public static List<CalibPoint> parse(File file) throws Exception {
        try (FileInputStream fis = new FileInputStream(file);
             Workbook wb = new XSSFWorkbook(fis)) {

            Sheet sheet = wb.getSheetAt(0);
            int maxRow = sheet.getLastRowNum() + 1;

            List<String> headers = new ArrayList<>();
            int dataStartRow = 0;

            // 查找表头行(前5行内)
            for (int rowIdx = 0; rowIdx < Math.min(5, maxRow); rowIdx++) {
                Row row = sheet.getRow(rowIdx);
                if (row == null) continue;
                List<String> rowVals = new ArrayList<>();
                for (int c = 0; c < row.getLastCellNum(); c++) {
                    Cell cell = row.getCell(c);
                    String v = cellToString(cell).trim();
                    rowVals.add(v);
                }
                String joined = String.join(",", rowVals).toLowerCase();
                if (joined.contains("imagex") || joined.contains("image_x") || joined.contains("像素x") || joined.contains("图像x") || joined.contains("pix_x") || joined.contains("x1")) {
                    headers = rowVals;
                    dataStartRow = rowIdx + 1;
                    break;
                }
            }

            if (headers.isEmpty()) {
                Row firstRow = sheet.getRow(0);
                if (firstRow != null) {
                    boolean allNumeric = true;
                    for (int c = 0; c < firstRow.getLastCellNum(); c++) {
                        Cell cell = firstRow.getCell(c);
                        if (cell != null && cell.getCellType() != CellType.NUMERIC && cell.getCellType() != CellType.BLANK) {
                            allNumeric = false;
                            break;
                        }
                    }
                    if (allNumeric) {
                        headers = new ArrayList<>();
                        headers.add("ImageX");
                        headers.add("ImageY");
                        headers.add("WorldX");
                        headers.add("WorldY");
                        dataStartRow = 0;
                    } else {
                        headers = new ArrayList<>();
                        for (int c = 0; c < firstRow.getLastCellNum(); c++) {
                            headers.add(cellToString(firstRow.getCell(c)).trim());
                        }
                        dataStartRow = 1;
                    }
                }
            }

            int colIx = findCol(headers, "imagex", "像素x", "图像x", "pixx", "x1", "image_x");
            int colIy = findCol(headers, "imagey", "像素y", "图像y", "pixy", "y1", "image_y");
            int colWx = findCol(headers, "worldx", "世界x", "机械x", "world_x");
            int colWy = findCol(headers, "worldy", "世界y", "机械y", "world_y");

            if (colIx < 0) colIx = 0;
            if (colIy < 0) colIy = 1;
            if (colWx < 0) colWx = 2;
            if (colWy < 0) colWy = 3;

            int maxCol = Math.max(Math.max(colIx, colIy), Math.max(colWx, colWy));

            List<CalibPoint> points = new ArrayList<>();
            for (int r = dataStartRow; r < maxRow; r++) {
                Row row = sheet.getRow(r);
                if (row == null) continue;
                if (row.getLastCellNum() <= maxCol) continue;

                Double ix = getNumeric(row, colIx);
                Double iy = getNumeric(row, colIy);
                Double wx = getNumeric(row, colWx);
                Double wy = getNumeric(row, colWy);

                if (ix != null && iy != null && wx != null && wy != null) {
                    points.add(new CalibPoint(ix, iy, wx, wy));
                }
            }

            if (points.size() < 3) {
                throw new IllegalArgumentException(
                        "从Excel中仅解析到 " + points.size() + " 个有效标定点,至少需要3个。\n" +
                        "请检查表头是否包含 ImageX/ImageY/WorldX/WorldY 或对应中文列名。");
            }

            return points;
        }
    }

    private static String cellToString(Cell cell) {
        if (cell == null) return "";
        switch (cell.getCellType()) {
            case STRING:
                return cell.getStringCellValue();
            case NUMERIC:
                if (DateUtil.isCellDateFormatted(cell)) {
                    return cell.getDateCellValue().toString();
                }
                return String.valueOf(cell.getNumericCellValue());
            case BOOLEAN:
                return String.valueOf(cell.getBooleanCellValue());
            case FORMULA:
                try {
                    return String.valueOf(cell.getNumericCellValue());
                } catch (Exception e) {
                    return cell.getStringCellValue();
                }
            default:
                return "";
        }
    }

    private static Double getNumeric(Row row, int col) {
        Cell cell = row.getCell(col);
        if (cell == null) return null;
        if (cell.getCellType() == CellType.NUMERIC) {
            return cell.getNumericCellValue();
        }
        if (cell.getCellType() == CellType.STRING) {
            try {
                return Double.parseDouble(cell.getStringCellValue().trim());
            } catch (NumberFormatException e) {
                return null;
            }
        }
        if (cell.getCellType() == CellType.FORMULA) {
            try {
                return cell.getNumericCellValue();
            } catch (Exception e) {
                return null;
            }
        }
        return null;
    }

    private static int findCol(List<String> headers, String... candidates) {
        for (int i = 0; i < headers.size(); i++) {
            String h = normalize(headers.get(i));
            for (String cand : candidates) {
                String cn = normalize(cand);
                if (h.equals(cn) || h.contains(cn) || cn.contains(h)) {
                    return i;
                }
            }
        }
        return -1;
    }

    private static String normalize(String s) {
        return s.toLowerCase().replace("_", "").replace(" ", "").replace("\t", "");
    }
}

util/ExcelTemplateWriter.java

package com.hikvision.calib.util;

import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;

import java.io.File;
import java.io.FileOutputStream;

/**
 * 创建 Excel 模板文件
 */
public class ExcelTemplateWriter {

    public static void write(File file) throws Exception {
        try (Workbook wb = new XSSFWorkbook()) {
            Sheet sheet = wb.createSheet("标定数据");

            // Header
            Row headerRow = sheet.createRow(0);
            String[] headers = {"ImageX", "ImageY", "WorldX", "WorldY"};
            for (int i = 0; i < headers.length; i++) {
                Cell cell = headerRow.createCell(i);
                cell.setCellValue(headers[i]);
            }

            // Example data
            double[][] examples = {
                    {1945.93, 3429.57, 101.5, 68.5},
                    {3814.40, 3428.67, 112.5, 68.5},
                    {1510.35, 2837.37, 97.0, 65.0},
                    {1291.36, 1465.91, 116.5, 65.0},
            };

            for (int r = 0; r < examples.length; r++) {
                Row row = sheet.createRow(r + 1);
                for (int c = 0; c < examples[r].length; c++) {
                    row.createCell(c).setCellValue(examples[r][c]);
                }
            }

            // Auto column width
            for (int c = 0; c < headers.length; c++) {
                int maxLen = headers[c].length();
                for (int r = 0; r < examples.length; r++) {
                    String s = String.valueOf(examples[r][c]);
                    if (s.length() > maxLen) maxLen = s.length();
                }
                sheet.setColumnWidth(c, Math.min((maxLen + 2) * 256, 20 * 256));
            }

            try (FileOutputStream fos = new FileOutputStream(file)) {
                wb.write(fos);
            }
        }
    }
}

util/MathUtils.java

package com.hikvision.calib.util;

import com.hikvision.calib.model.CalibMatrix;
import com.hikvision.calib.model.CalibPoint;
import org.ejml.data.DMatrixRMaj;
import org.ejml.dense.row.factory.LinearSolverFactory_DDRM;
import org.ejml.interfaces.linsol.LinearSolverDense;

import java.util.ArrayList;
import java.util.List;

/**
 * 矩阵计算与误差统计工具
 */
public class MathUtils {

    /**
     * 最小二乘法计算 3x3 仿射变换矩阵(6自由度)
     */
    public static CalibMatrix computeHomographyMatrix(List<CalibPoint> points) {
        int n = points.size();
        if (n < 3) {
            throw new IllegalArgumentException("至少需要3个标定点才能计算变换矩阵");
        }

        DMatrixRMaj A = new DMatrixRMaj(2 * n, 6);
        DMatrixRMaj B = new DMatrixRMaj(2 * n, 1);

        for (int i = 0; i < n; i++) {
            CalibPoint p = points.get(i);
            double ix = p.getImageX();
            double iy = p.getImageY();
            double wx = p.getWorldX();
            double wy = p.getWorldY();

            A.set(2 * i, 0, ix);
            A.set(2 * i, 1, iy);
            A.set(2 * i, 2, 1.0);
            A.set(2 * i, 3, 0.0);
            A.set(2 * i, 4, 0.0);
            A.set(2 * i, 5, 0.0);
            B.set(2 * i, 0, wx);

            A.set(2 * i + 1, 0, 0.0);
            A.set(2 * i + 1, 1, 0.0);
            A.set(2 * i + 1, 2, 0.0);
            A.set(2 * i + 1, 3, ix);
            A.set(2 * i + 1, 4, iy);
            A.set(2 * i + 1, 5, 1.0);
            B.set(2 * i + 1, 0, wy);
        }

        LinearSolverDense<DMatrixRMaj> solver = LinearSolverFactory_DDRM.leastSquares(2 * n, 6);
        if (!solver.setA(A)) {
            throw new RuntimeException("矩阵求解失败");
        }

        DMatrixRMaj X = new DMatrixRMaj(6, 1);
        solver.solve(B, X);

        double[] matrix = new double[]{
                X.get(0, 0), X.get(1, 0), X.get(2, 0),
                X.get(3, 0), X.get(4, 0), X.get(5, 0),
                0.0, 0.0, 1.0
        };
        return new CalibMatrix(matrix);
    }

    /**
     * 计算平均重投影误差(TransError)
     */
    public static double computeTransError(List<CalibPoint> points, CalibMatrix matrix) {
        double sum = 0.0;
        for (CalibPoint p : points) {
            double[] pred = matrix.imageToWorld(p.getImageX(), p.getImageY());
            double err = Math.hypot(p.getWorldX() - pred[0], p.getWorldY() - pred[1]);
            sum += err;
        }
        return points.isEmpty() ? -999.0 : sum / points.size();
    }

    /**
     * 计算每个点的世界坐标单位误差,返回 (errors, max, mean, rmse)
     */
    public static ErrorResult computeWorldUnitErrors(List<CalibPoint> points, CalibMatrix matrix) {
        List<Double> errors = new ArrayList<>();
        double sum = 0.0;
        double sumSq = 0.0;
        double maxErr = 0.0;

        for (CalibPoint p : points) {
            double[] pred = matrix.imageToWorld(p.getImageX(), p.getImageY());
            double err = Math.hypot(p.getWorldX() - pred[0], p.getWorldY() - pred[1]);
            errors.add(err);
            sum += err;
            sumSq += err * err;
            if (err > maxErr) maxErr = err;
        }

        int n = points.size();
        double mean = n > 0 ? sum / n : 0.0;
        double rmse = n > 0 ? Math.sqrt(sumSq / n) : 0.0;
        return new ErrorResult(errors, maxErr, mean, rmse);
    }

    /**
     * 估算像素精度
     */
    public static double[] computePixelPrecision(List<CalibPoint> points) {
        if (points.size() < 2) {
            return new double[]{0.005, 0.005, 0.005};
        }

        List<Double> ratios = new ArrayList<>();
        for (int i = 0; i < points.size(); i++) {
            for (int j = i + 1; j < points.size(); j++) {
                CalibPoint pi = points.get(i);
                CalibPoint pj = points.get(j);
                double pixDist = Math.hypot(pi.getImageX() - pj.getImageX(), pi.getImageY() - pj.getImageY());
                double worldDist = Math.hypot(pi.getWorldX() - pj.getWorldX(), pi.getWorldY() - pj.getWorldY());
                if (pixDist > 1e-6) {
                    ratios.add(worldDist / pixDist);
                }
            }
        }

        if (ratios.isEmpty()) {
            return new double[]{0.005, 0.005, 0.005};
        }

        double avg = ratios.stream().mapToDouble(Double::doubleValue).average().orElse(0.005);
        return new double[]{avg, avg, avg};
    }

    public static class ErrorResult {
        public final List<Double> errors;
        public final double maxError;
        public final double meanError;
        public final double rmse;

        public ErrorResult(List<Double> errors, double maxError, double meanError, double rmse) {
            this.errors = errors;
            this.maxError = maxError;
            this.meanError = meanError;
            this.rmse = rmse;
        }
    }
}

util/XmlGenerator.java

package com.hikvision.calib.util;

import com.hikvision.calib.model.CalibInfo;
import com.hikvision.calib.model.CalibPoint;
import org.w3c.dom.Document;
import org.w3c.dom.Element;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.io.StringWriter;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

/**
 * 生成海康标定 XML
 */
public class XmlGenerator {

    public static String generate(CalibInfo info) throws Exception {
        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
        DocumentBuilder builder = factory.newDocumentBuilder();
        Document doc = builder.newDocument();

        Element root = doc.createElement("CalibInfo");
        doc.appendChild(root);

        // CalibInputParam
        Element inp = doc.createElement("CalibInputParam");
        root.appendChild(inp);

        String createTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
        addParam(doc, inp, "CreateCalibTime", "string", createTime);
        addParam(doc, inp, "CalibType", "string", info.getCalibType());
        addParam(doc, inp, "CameraMode", "int", info.getCameraMode());
        addParam(doc, inp, "TransNum", "int", String.valueOf(info.getPoints().size()));
        addParam(doc, inp, "RotNum", "int", String.valueOf(info.getRotNum()));
        addParam(doc, inp, "CalibErrStatus", "int", String.valueOf(info.getCalibErrStatus()));
        addParam(doc, inp, "TransError", "float", formatG(info.getTransError()));
        addParam(doc, inp, "RotError", "float", String.valueOf(info.getRotError()));
        addParam(doc, inp, "TransWorldError", "float", formatG(info.getTransError()));
        addParam(doc, inp, "RotWorldError", "float", String.valueOf(info.getRotWorldError()));
        addParam(doc, inp, "PixelPrecisionX", "float", formatG(info.getPixelPrecisionX()));
        addParam(doc, inp, "PixelPrecisionY", "float", formatG(info.getPixelPrecisionY()));
        addParam(doc, inp, "PixelPrecision", "float", formatG(info.getPixelPrecision()));

        // ImagePointLst
        Element imgList = doc.createElement("CalibPointFListParam");
        imgList.setAttribute("ParamName", "ImagePointLst");
        imgList.setAttribute("DataType", "CalibPointList");
        for (CalibPoint p : info.getPoints()) {
            addPointF(doc, imgList, p.getImageX(), p.getImageY());
        }
        inp.appendChild(imgList);

        // WorldPointLst
        Element worldList = doc.createElement("CalibPointFListParam");
        worldList.setAttribute("ParamName", "WorldPointLst");
        worldList.setAttribute("DataType", "CalibPointList");
        for (CalibPoint p : info.getPoints()) {
            addPointF(doc, worldList, p.getWorldX(), p.getWorldY());
        }
        inp.appendChild(worldList);

        // CalibOutputParam
        Element out = doc.createElement("CalibOutputParam");
        root.appendChild(out);

        addParam(doc, out, "RotDirectionState", "int", String.valueOf(info.getRotDirectionState()));
        addParam(doc, out, "IsRightCoorA", "int", String.valueOf(info.getIsRightCoorA()));

        // RotCenterImagePoint
        Element rcImg = doc.createElement("PointF");
        rcImg.setAttribute("ParamName", "RotCenterImagePoint");
        rcImg.setAttribute("DataType", "CalibPointF");
        addChild(doc, rcImg, "RotCenterImagePointX", String.valueOf(info.getRotCenterImage()[0]));
        addChild(doc, rcImg, "RotCenterImagePointY", String.valueOf(info.getRotCenterImage()[1]));
        addChild(doc, rcImg, "RotCenterImageR", String.valueOf(info.getRotCenterImage()[2]));
        out.appendChild(rcImg);

        // RotCenterWorldPoint
        Element rcWorld = doc.createElement("PointF");
        rcWorld.setAttribute("ParamName", "RotCenterWorldPoint");
        rcWorld.setAttribute("DataType", "CalibPointF");
        addChild(doc, rcWorld, "RotCenterWorldPointX", String.valueOf(info.getRotCenterWorld()[0]));
        addChild(doc, rcWorld, "RotCenterWorldPointY", String.valueOf(info.getRotCenterWorld()[1]));
        addChild(doc, rcWorld, "RotCenterWorldR", String.valueOf(info.getRotCenterWorld()[2]));
        out.appendChild(rcWorld);

        // CalibMatrix
        Element mat = doc.createElement("CalibFloatListParam");
        mat.setAttribute("ParamName", "CalibMatrix");
        mat.setAttribute("DataType", "FloatList");
        double[] mv = info.getMatrix().getValues();
        for (double v : mv) {
            addChild(doc, mat, "ParamValue", formatG(v));
        }
        out.appendChild(mat);

        // Format
        TransformerFactory tf = TransformerFactory.newInstance();
        Transformer transformer = tf.newTransformer();
        transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes");
        transformer.setOutputProperty(OutputKeys.INDENT, "yes");
        transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "4");

        StringWriter writer = new StringWriter();
        transformer.transform(new DOMSource(doc), new StreamResult(writer));

        String body = writer.toString().trim();
        // Remove empty text nodes lines
        StringBuilder sb = new StringBuilder();
        for (String line : body.split("\n")) {
            if (!line.trim().isEmpty()) {
                sb.append(line).append("\n");
            }
        }
        return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" + sb.toString().trim();
    }

    private static void addParam(Document doc, Element parent, String name, String dtype, String value) {
        Element p = doc.createElement("CalibParam");
        p.setAttribute("ParamName", name);
        p.setAttribute("DataType", dtype);
        Element pv = doc.createElement("ParamValue");
        pv.setTextContent(value);
        p.appendChild(pv);
        parent.appendChild(p);
    }

    private static void addPointF(Document doc, Element parent, double x, double y) {
        Element pf = doc.createElement("PointF");
        addChild(doc, pf, "X", String.valueOf(x));
        addChild(doc, pf, "Y", String.valueOf(y));
        addChild(doc, pf, "R", "0");
        parent.appendChild(pf);
    }

    private static void addChild(Document doc, Element parent, String tag, String text) {
        Element e = doc.createElement(tag);
        e.setTextContent(text);
        parent.appendChild(e);
    }

    private static String formatG(double v) {
        String s = String.format("%.7g", v);
        // avoid -0.000000
        if (s.equals("-0") || s.equals("-0.0") || s.equals("-0.00") || s.equals("-0.000") || s.equals("-0.0000") || s.equals("-0.00000") || s.equals("-0.000000")) {
            return "0";
        }
        return s;
    }
}

util/XmlParser.java

package com.hikvision.calib.util;

import com.hikvision.calib.model.CalibMatrix;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.File;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * 从海康标定 XML 解析矩阵和辅助信息
 */
public class XmlParser {

    public static ParseResult parse(File file) throws Exception {
        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
        DocumentBuilder builder = factory.newDocumentBuilder();
        Document doc = builder.parse(file);
        doc.getDocumentElement().normalize();

        List<Double> matrixValues = new ArrayList<>();
        Map<String, String> info = new HashMap<>();

        // Find CalibMatrix
        NodeList calibFloatListParams = doc.getElementsByTagName("CalibFloatListParam");
        for (int i = 0; i < calibFloatListParams.getLength(); i++) {
            Element elem = (Element) calibFloatListParams.item(i);
            String paramName = elem.getAttribute("ParamName");
            if ("CalibMatrix".equals(paramName)) {
                NodeList paramValues = elem.getElementsByTagName("ParamValue");
                for (int j = 0; j < paramValues.getLength(); j++) {
                    Node node = paramValues.item(j);
                    String text = node.getTextContent();
                    if (text != null) {
                        try {
                            matrixValues.add(Double.parseDouble(text.trim()));
                        } catch (NumberFormatException ignored) {
                        }
                    }
                }
                break;
            }
        }

        if (matrixValues.size() != 9) {
            throw new IllegalArgumentException(
                    "XML中未找到有效的9元素标定矩阵,实际读取到 " + matrixValues.size() + " 个值");
        }

        // Extract auxiliary info
        NodeList calibParams = doc.getElementsByTagName("CalibParam");
        for (int i = 0; i < calibParams.getLength(); i++) {
            Element elem = (Element) calibParams.item(i);
            String name = elem.getAttribute("ParamName");
            NodeList pvList = elem.getElementsByTagName("ParamValue");
            if (pvList.getLength() > 0) {
                String text = pvList.item(0).getTextContent();
                if (text != null) {
                    info.put(name, text.trim());
                }
            }
        }

        double[] arr = new double[9];
        for (int i = 0; i < 9; i++) {
            arr[i] = matrixValues.get(i);
        }
        return new ParseResult(new CalibMatrix(arr), info);
    }

    public static class ParseResult {
        public final CalibMatrix matrix;
        public final Map<String, String> info;

        public ParseResult(CalibMatrix matrix, Map<String, String> info) {
            this.matrix = matrix;
            this.info = info;
        }
    }
}
posted @ 2026-06-13 20:59  qsBye  阅读(2)  评论(0)    收藏  举报