标定板在线生成器
使用方法:
1、标定板参数设置。设置标定板参数,如圆点标定板则设置普通圆点半径大小以及点间距,并控制生成行列数,添加定位点(行、列、半径);棋盘格则只需设置行和列及间距。
2、电子标定板。点击“全屏”,你将通过显示屏获得一个电子标定板,其圆点坐标即为实际物理坐标(mm)。
3、打印标定板。你也可以选择“打印”,将标定板打印在纸张上,长度单位也是实际物理坐标(mm)。
4、标定板文件。你可以选择导入和导出 json 格式的标定板数据,用来保存标定板和快速导入标定板。
实际的显示器dpi可能不一样,请使用下方的标尺结合实际物理长度进行dpi校准
参考资料:https://calib.io/pages/camera-calibration-pattern-generator
<!-- run -->
<style>
.calib-board-container {
width: 100%;
height: 600px;
margin: 0 auto;
/* overflow: hidden; */
}
.calib-board {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
display: flex;
gap: 10px;
font-family: sans-serif;
align-items: center;
justify-content: center;
}
#dpiCalibration span {
color: #606266;
font-size: 14px;
margin-bottom: 10px;
}
#decreaseLength {
border-radius: 4px 0 0 4px;
border-right-width: 0;
}
#increaseLength {
border-radius: 0 4px 4px 0;
border-left-width: 0;
}
@media screen and (max-width: 767px) {
.calib-board {
flex-direction: column;
}
#controls {
width: 100%;
}
}
#svg-container{
border: 1px solid lightgray;
width: 100%;
height: 100%;
overflow: hidden;
background: white;
cursor: grab;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
#svgContainer {
width: 100%;
height: 100%;
overflow: hidden;
background: white;
cursor: grab;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.full-screen#svgContainer{
border:none!important;
}
#controls {
/* position: sticky; */
float: right;
top: 10px;
right: 30px;
max-width: 400px;
width: 100%;
height: 100%;
border-radius: 4px;
background: rgba(255, 255, 255, 0.95);
padding: 15px;
border-radius: 4px;
border: 1px solid lightgray;
z-index: 10;
display: flex;
flex-direction: column;
gap: 10px;
overflow-y: scroll;
}
#controls label {
display: flex;
gap: 20px;
}
#controls label input {
flex: 1;
}
.custom-point {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.custom-point input {
width: 60px;
padding: 4px;
}
.custom-point .rad {
width: 80px;
}
.custom-point button {
background: none;
border: none;
color: #d00;
font-size: 16px;
cursor: pointer;
}
.my-form {
display: flex;
gap: 10px;
}
.my-form .el-form-item {
margin-bottom: 0px;
}
.functions button,
.custom-point input {
width: 100%;
}
@media print {
.postBody{
margin:auto!important;
padding:0!important;
}
#blog_post_info_block,
#comment_form_container,
#footer,
.float-btn,
.postDesc,
.postTitle,
#header,
#controls,
#MySignature,
#note,
.post-header {
display: none !important;
}
* {
margin: 0 !important;
padding: 0 !important;
visibility: hidden;
}
.forFlow,
#cnblogs_post_body {
width: 100% !important;
max-width: 100% !important;
height: 100% !important;
display: flex !important;
align-items: ceter;
justify-content: center;
}
.print-element,
.print-element>* {
visibility: visible !important;
}
#svgContainer,.calib-board-container {
width: 100vw;
height: 100vh;
border:none!important;
}
@page {
margin: 0;
}
}
</style>
<div id="app">
<div class="calib-board-container">
<div class="calib-board">
<div id="svg-container">
<div id="svgContainer" class="print-element">
<svg class="print-element" id="board" xmlns="http://www.w3.org/2000/svg"></svg>
</div>
</div>
<div id="controls">
<!-- 下拉选择器 -->
<el-form class="my-form">
<el-form-item label="选择图案">
<el-select v-model="patternType" @change="draw">
<el-option label="圆点点阵" value="dots"></el-option>
<el-option label="棋盘格" value="checkerboard"></el-option>
</el-select>
</el-form-item>
<el-form-item label="背景着色">
<el-select v-model="backgroundType" @change="color">
<el-option label="黑色" value="black"></el-option>
<el-option label="白色" value="white"></el-option>
</el-select>
</el-form-item>
</el-form>
<!-- 使用 Element UI 的 Input 组件 -->
<el-form class="my-form">
<el-form-item label="普通点半径(mm)" v-if="patternType === 'dots'">
<el-input v-model="settings.radius" type="number" step="0.1" @input="draw"></el-input>
</el-form-item>
<el-form-item label="网格宽度(mm)" v-if="patternType === 'checkerboard'">
<el-input v-model="settings.gridWidth" type="number" step="1" @input="draw"></el-input>
</el-form-item>
<el-form-item label="点间距(mm)" v-if="patternType === 'dots'">
<el-input v-model="settings.spacing" type="number" step="1" @input="draw"></el-input>
</el-form-item>
</el-form>
<el-form class="my-form">
<el-form-item label="行数">
<el-input v-model="settings.rows" type="number" step="1" @input="draw"></el-input>
</el-form-item>
<el-form-item label="列数">
<el-input v-model="settings.cols" type="number" step="1" @input="draw"></el-input>
</el-form-item>
</el-form>
<!-- 使用 Element UI 的 Button 组件 -->
<el-button v-if="patternType === 'dots'" @click="addCustomPoint">添加定位点</el-button>
<div id="customPointList" v-if="patternType === 'dots'">
<div v-for="(point, index) in settings.customPoints" :key="index" class="custom-point">
<el-input v-model="point.row" type="number" min="1" placeholder="行"
@input="draw"></el-input>
<el-input v-model="point.col" type="number" min="1" placeholder="列"
@input="draw"></el-input>
<el-input v-model="point.radius" type="number" step="0.1" placeholder="半径"
@input="draw"></el-input>
<el-button @click="removeCustomPoint(index)" type="danger"
icon="el-icon-delete"></el-button>
</div>
</div>
<el-row :gutter="10" class="functions">
<el-col :span="8"><el-button icon="el-icon-refresh" @click="resetView">重置</el-button></el-col>
<el-col :span="8"><el-button icon="el-icon-full-screen"
@click="toggleFullScreen">全屏</el-button></el-col>
<el-col :span="8"><el-button icon="el-icon-printer" @click="printPDF">打印</el-button></el-col>
</el-row>
<el-row :gutter="10" class="functions">
<el-col :span="12"><el-button icon="el-icon-upload2"
@click="exportPoints">导出</el-button></el-col>
<el-col :span="12"><el-button icon="el-icon-download"
@click="importPoints">导入</el-button></el-col>
<input type="file" id="fileInput" style="display: none" @change="handleFileImport">
</el-row>
<div id="dpiCalibration" style="margin-top: 10px;">
<span>拖动调整标尺线长度使其与真实 10mm 一致:</span>
<el-row :gutter="10" style="display: flex;">
<el-button id="decreaseLength">-</el-button>
<div
style="position: relative; width: 100%; height: 50px; background: #eee; border: 1px solid #ccc;">
<div id="rulerLine"
style="position: absolute; height: 100%; background: #000; width: 50px; cursor: ew-resize;">
</div>
</div>
<el-button id="increaseLength">+</el-button>
</el-row>
<span>当前长度:<span id="rulerLength">50</span> px; DPI为 <span id="dpiVal">96.000</span></span>
</div>
</div>
</div>
</div>
</div>
<script>
new Vue({
el: '#app',
data() {
return {
patternType: 'dots',
settings: {
radius: 1,
spacing: 10,
rows: 20,
cols: 30,
customPoints: [],
gridWidth: 10
},
offsetX: 0,
offsetY: 0,
dragging: false,
lastX: 0,
lastY: 0,
DPI: 96,
rulerPx: 300,
rulerMm: 100,
calibratedDPI: 96,
MM_PER_INCH: 25.4,
file: null,
backgroundType:"white",
backgroundColor:"#fff",
targetColor:"#000",
fileContent: ''
};
},
mounted() {
this.draw();
this.color();
const container = document.getElementById("svgContainer");
const controls = document.getElementById("controls");
const btnIncrease = document.getElementById("increaseLength");
const btnDecrease = document.getElementById("decreaseLength");
container.addEventListener("mousedown", (e) => {
this.dragging = true;
this.lastX = e.clientX;
this.lastY = e.clientY;
container.style.cursor = "grabbing";
});
container.addEventListener("mousemove", (e) => {
if (this.dragging) {
this.offsetX += e.clientX - this.lastX;
this.offsetY += e.clientY - this.lastY;
this.lastX = e.clientX;
this.lastY = e.clientY;
this.draw();
}
});
container.addEventListener("mouseup", () => {
this.dragging = false;
container.style.cursor = "grab";
});
container.addEventListener("mouseleave", () => {
this.dragging = false;
container.style.cursor = "grab";
});
window.addEventListener("resize", () => this.draw());
document.addEventListener("fullscreenchange", () => {
if (document.fullscreenElement) {
controls.style.display = "none";
} else {
controls.style.display = "flex";
}
});
window.addEventListener("beforeprint", () => {
this.resetView();
document.getElementById("svgContainer").style.backgroundColor = this.backgroundColor;
if (this.patternType === 'dots') {
const { radius, spacing, rows, cols, customPoints } = this.settings;
const spacingPx = this.mmToPx(spacing);
const defaultRadiusPx = this.mmToPx(radius);
const customMap = new Map();
customPoints.forEach((pt) => {
customMap.set(`${pt.row}-${pt.col}`, this.mmToPx(pt.radius));
});
/* 计算矩形区域 A 的尺寸*/
const areaAWidth = cols * spacingPx;
const areaAHeight = rows * spacingPx;
/* 计算扩展距离(2 倍点间距)*/
const expandDistance = 2 * spacingPx;
/*计算矩形区域 B 的尺寸*/
const areaBWidth = areaAWidth + 2 * expandDistance;
const areaBHeight = areaAHeight + 2 * expandDistance;
/* 设置 SVG 的尺寸为矩形区域 B 的尺寸*/
const svg = document.getElementById("board");
svg.setAttribute("width", areaBWidth);
svg.setAttribute("height", areaBHeight);
svg.setAttribute("fill",this.backgroundColor);
/*计算点阵在矩形区域 B 中的偏移量*/
const newOffsetX = expandDistance;
const newOffsetY = expandDistance;
svg.innerHTML = "";
for (let row = 1; row <= rows; row++) {
for (let col = 1; col <= cols; col++) {
const x = (col - 0.5) * spacingPx + newOffsetX;
const y = (row - 0.5) * spacingPx + newOffsetY;
const key = `${row}-${col}`;
const r = customMap.has(key) ? customMap.get(key) : defaultRadiusPx;
const circle = document.createElementNS(
"http://www.w3.org/2000/svg",
"circle"
);
circle.setAttribute("cx", x);
circle.setAttribute("cy", y);
circle.setAttribute("r", r);
circle.setAttribute("fill", this.targetColor);
svg.appendChild(circle);
}
}
}
else if (this.patternType === 'checkerboard'){
const { rows, cols, gridWidth } = this.settings;
const gridWidthPx = this.mmToPx(gridWidth);
/* 计算矩形区域 A 的尺寸*/
const areaAWidth = cols * gridWidthPx;
const areaAHeight = rows * gridWidthPx;
/* 计算扩展距离(2 倍点间距)*/
const expandDistance = 2 * gridWidthPx;
/*计算矩形区域 B 的尺寸*/
const areaBWidth = areaAWidth + 2 * expandDistance;
const areaBHeight = areaAHeight + 2 * expandDistance;
/* 设置 SVG 的尺寸为矩形区域 B 的尺寸*/
const svg = document.getElementById("board");
svg.setAttribute("width", areaBWidth);
svg.setAttribute("height", areaBHeight);
svg.setAttribute("fill",this.backgroundColor);
/*计算点阵在矩形区域 B 中的偏移量*/
const newOffsetX = expandDistance;
const newOffsetY = expandDistance;
svg.innerHTML = "";
for (let row = 1; row <= rows; row++) {
for (let col = 1; col <= cols; col++) {
const x = (col - 0.5) * gridWidthPx + newOffsetX;
const y = (row - 0.5) * gridWidthPx + newOffsetY;
const rect = document.createElementNS(
"http://www.w3.org/2000/svg",
"rect"
);
rect.setAttribute("x", x);
rect.setAttribute("y", y);
rect.setAttribute("width", gridWidthPx);
rect.setAttribute("height", gridWidthPx);
rect.setAttribute("fill", (row + col) % 2 === 0 ? this.targetColor : this.backgroundColor);
svg.appendChild(rect);
}
}
}
/* 隐藏控件*/
controls.style.display = "none";
});
window.addEventListener("afterprint", () => {
this.draw();
/* 显示控件*/
controls.style.display = "flex";
});
const ruler = document.getElementById("rulerLine");
const lengthDisplay = document.getElementById("rulerLength");
let draggingRuler = false;
ruler.addEventListener("mousedown", (e) => {
draggingRuler = true;
e.preventDefault();
});
document.addEventListener("mousemove", (e) => {
if (draggingRuler) {
const parent = ruler.parentElement;
const rect = parent.getBoundingClientRect();
let newWidth = e.clientX - rect.left;
console.log("newWidth", newWidth);
newWidth = Math.max(10, Math.min(parent.offsetWidth, newWidth));
ruler.style.width = newWidth + "px";
lengthDisplay.textContent = newWidth.toFixed(0);
this.applyDpiCalibration();
}
});
btnIncrease.addEventListener("click", () => {
const parent = ruler.parentElement;
let currentWidth = ruler.offsetWidth;
let newWidth = Math.min(parent.offsetWidth, currentWidth + 1);
ruler.style.width = newWidth + "px";
lengthDisplay.textContent = newWidth.toFixed(0);
this.applyDpiCalibration();
});
btnDecrease.addEventListener("click", () => {
let currentWidth = ruler.offsetWidth;
let newWidth = Math.max(10, currentWidth - 1);
ruler.style.width = newWidth + "px";
lengthDisplay.textContent = newWidth.toFixed(0);
this.applyDpiCalibration();
});
document.addEventListener("mouseup", () => {
draggingRuler = false;
});
},
methods: {
mmToPx(mm) {
return mm * this.DPI / this.MM_PER_INCH;
},
pxToMm(px) {
return px * this.MM_PER_INCH / this.DPI;
},
/** 更新 DPI(从 rulerPx 和 rulerMm 得出) */
updateDPIFromRuler() {
if (this.rulerPx > 0 && this.rulerMm > 0) {
this.DPI = this.rulerPx * this.MM_PER_INCH / this.rulerMm;
this.draw();
}
},
/** 当用户拖拽时设置新 rulerPx 并刷新 DPI */
setRulerPx(newPx) {
this.rulerPx = newPx;
this.updateDPIFromRuler();
},
/** 当用户点击按钮修改毫米时 */
changeRulerMm(delta) {
this.rulerMm = Math.max(1, this.rulerMm + delta);
this.updateDPIFromRuler();
},
color(){
if(this.backgroundType === 'white')
{
this.backgroundColor = "#fff";
this.targetColor = "#000";
}
if(this.backgroundType === 'black')
{
this.backgroundColor = "#000";
this.targetColor = "#fff";
}
this.draw();
},
draw() {
document.getElementById("svgContainer").style.backgroundColor = this.backgroundColor;
if (this.patternType === 'dots') {
const { radius, spacing, rows, cols, customPoints } = this.settings;
const spacingPx = this.mmToPx(spacing);
const defaultRadiusPx = this.mmToPx(radius);
const customMap = new Map();
customPoints.forEach((pt) => {
customMap.set(`${pt.row}-${pt.col}`, this.mmToPx(pt.radius));
});
const svg = document.getElementById("board");
svg.innerHTML = "";
const svgWidth = cols * spacingPx;
const svgHeight = rows * spacingPx;
const post = document.querySelector('#svgContainer');
const postWidth = post.offsetWidth;
const postHeight = post.offsetHeight;
const viewBoxOffsetX = this.offsetX + (postWidth - svgWidth) / 2;
const viewBoxOffsetY = this.offsetY + (postHeight - svgHeight) / 2;
svg.setAttribute("width", postWidth);
svg.setAttribute("height", postHeight);
svg.setAttribute("fill",this.backgroundColor);
for (let row = 1; row <= rows; row++) {
for (let col = 1; col <= cols; col++) {
const x = (col - 0.5) * spacingPx + viewBoxOffsetX;
const y = (row - 0.5) * spacingPx + viewBoxOffsetY;
const key = `${row}-${col}`;
const r = customMap.has(key) ? customMap.get(key) : defaultRadiusPx;
const circle = document.createElementNS(
"http://www.w3.org/2000/svg",
"circle"
);
circle.setAttribute("cx", x);
circle.setAttribute("cy", y);
circle.setAttribute("r", r);
circle.setAttribute("fill", this.targetColor);
svg.appendChild(circle);
}
}
}
else if (this.patternType === 'checkerboard') {
const { rows, cols, gridWidth } = this.settings;
const gridWidthPx = this.mmToPx(gridWidth);
const svg = document.getElementById("board");
svg.innerHTML = "";
const svgWidth = cols * gridWidthPx;
const svgHeight = rows * gridWidthPx;
const post = document.querySelector('#svgContainer');
const postWidth = post.offsetWidth;
const postHeight = post.offsetHeight;
const viewBoxOffsetX = this.offsetX + (postWidth - svgWidth) / 2;
const viewBoxOffsetY = this.offsetY + (postHeight - svgHeight) / 2;
svg.setAttribute("width", postWidth);
svg.setAttribute("height", postHeight);
svg.setAttribute("fill",this.backgroundColor);
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const x = col * gridWidthPx + viewBoxOffsetX;
const y = row * gridWidthPx + viewBoxOffsetY;
const rect = document.createElementNS(
"http://www.w3.org/2000/svg",
"rect"
);
rect.setAttribute("x", x);
rect.setAttribute("y", y);
rect.setAttribute("width", gridWidthPx);
rect.setAttribute("height", gridWidthPx);
/*rect.setAttribute("fill", (row + col) % 2 === 0 ? "#000" : "#fff");*/
rect.setAttribute("fill", (row + col) % 2 === 0 ? this.backgroundColor : this.targetColor);
svg.appendChild(rect);
}
}
}
},
addCustomPoint() {
this.settings.customPoints.push({ row: null, col: null, radius: null });
this.draw();
},
removeCustomPoint(index) {
this.settings.customPoints.splice(index, 1);
this.draw();
},
resetView() {
this.offsetX = 0;
this.offsetY = 0;
this.draw();
},
toggleFullScreen() {
const container = document.querySelector('#svgContainer');
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
container.requestFullscreen();
}
},
printPDF() {
window.print();
},
exportPoints() {
let data = {};
let fileName = "";
if (this.patternType === 'dots') {
const { radius, spacing, rows, cols, customPoints } = this.settings;
const boardInfo = {
radius,
spacing,
rows,
cols,
customPoints
};
const points = [];
let id = 0;
for (let row = 1; row <= rows; row++) {
for (let col = 1; col <= cols; col++) {
const isCustom = customPoints.some(point => point.row === row && point.col === col);
const pointRadius = isCustom ? customPoints.find(point => point.row === row && point.col === col).radius : radius;
points.push({
id: id,
x: row * spacing,
y: col * spacing,
radius: pointRadius
});
id++;
}
}
data = {
boardInfo,
points
};
fileName = `CircleBoard ${rows}×${cols}-${radius}mm.json`;
}
else if (this.patternType === 'checkerboard') {
const { rows, cols, gridWidth, customPoints } = this.settings;
const boardInfo = {
gridWidth,
rows,
cols,
customPoints
};
const points = [];
let id = 0;
for (let row = 1; row <= rows; row++) {
for (let col = 1; col <= cols; col++) {
points.push({
id: id,
x: row * gridWidth,
y: col * gridWidth
});
id++;
}
}
data = {
boardInfo,
points
};
fileName = `CheckerBoard ${rows}×${cols}-${gridWidth}mm.json`;
}
const jsonData = JSON.stringify(data, null, 2);
const blob = new Blob([jsonData], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
},
importPoints() {
document.getElementById('fileInput').click();
},
handleFileImport(event) {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
if (this.patternType === 'dots') {
reader.onload = (e) => {
const data = JSON.parse(e.target.result);
const { boardInfo } = data;
this.settings.radius = boardInfo.radius;
this.settings.spacing = boardInfo.spacing;
this.settings.rows = boardInfo.rows;
this.settings.cols = boardInfo.cols;
this.settings.customPoints = boardInfo.customPoints;
this.draw();
};
reader.readAsText(file);
} else if (this.patternType === 'checkerboard') {
reader.onload = (e) => {
const data = JSON.parse(e.target.result);
const { boardInfo } = data;
this.settings.gridWidth = boardInfo.gridWidth;
this.settings.rows = boardInfo.rows;
this.settings.cols = boardInfo.cols;
this.settings.customPoints = boardInfo.customPoints;
this.draw();
};
reader.readAsText(file);
}
}
},
getDPI() {
var dpiDiv = document.createElement('div');
dpiDiv.style.cssText = 'width:1in;height:1in;position:absolute;left:-100%;top:-100%;';
document.body.appendChild(dpiDiv);
var dpi = dpiDiv.offsetWidth;
document.body.removeChild(dpiDiv);
console.log('dpi', dpi);
return dpi;
},
applyDpiCalibration() {
const pxLength = parseFloat(document.getElementById("rulerLength").textContent);
const mm = 10;
const newDPI = pxLength * this.MM_PER_INCH / mm;
this.calibratedDPI = newDPI;
this.DPI = newDPI;
const dpiVal = document.getElementById("dpiVal");
dpiVal.textContent = this.DPI.toFixed(3);
this.draw();
}
}
});
</script>
未经作者授权,禁止转载
THE END

可以修改参数自动生成标定板。
浙公网安备 33010602011771号