摸鱼笔记[5]-海康VisionMaster九点标定生成工具
摘要
基于java的VisionMaster的标定文件生成小工具.
声明
本文人类为第一作者, 龙虾为通讯作者. 本文有AI生成内容.
简介
九点标定简介
通过让相机拍摄(或运动平台移动至)9 个已知位置的特征点,建立像素坐标系与机械/物理坐标系之间的映射关系。
- 准备标定板或标定特征(圆点、十字、棋盘格等)
- 运动平台依次移动到 9 个预设位置
- 相机拍摄每个位置的图像,提取像素坐标 (uᵢ, vᵢ)
- 记录对应的物理坐标 (Xᵢ, Yᵢ)
- 计算变换矩阵(仿射/透视变换)
- 验证精度,补偿误差
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 张图片生成完成!")
参考图片:
| 九点标定图片 |
|---|
![]() |
生成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;
}
}
}


浙公网安备 33010602011771号