431 lines
13 KiB
HTML
431 lines
13 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>人脸标注工具</title>
|
||
<style>
|
||
body { font-family: "Segoe UI", Roboto, Helvetica, Arial, sans-serif; display: flex; height: 100vh; margin: 0; overflow: hidden; background-color: #2c3e50; color: #ecf0f1; }
|
||
|
||
#sidebar {
|
||
width: 250px;
|
||
background: #34495e;
|
||
border-right: 1px solid #2c3e50;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
#sidebar h3 {
|
||
padding: 15px;
|
||
margin: 0;
|
||
background: #2c3e50;
|
||
text-align: center;
|
||
border-bottom: 1px solid #34495e;
|
||
}
|
||
|
||
#file-list {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 10px;
|
||
}
|
||
|
||
#main {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 20px;
|
||
position: relative;
|
||
}
|
||
|
||
#canvas-container {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 100%;
|
||
overflow: hidden;
|
||
background: #2c3e50; /* Match body bg */
|
||
}
|
||
|
||
canvas {
|
||
background: #000;
|
||
cursor: crosshair;
|
||
box-shadow: 0 0 20px rgba(0,0,0,0.5);
|
||
border: 2px solid #95a5a6;
|
||
}
|
||
|
||
.img-item {
|
||
cursor: pointer;
|
||
padding: 10px;
|
||
margin-bottom: 5px;
|
||
background: #ecf0f1;
|
||
color: #2c3e50;
|
||
border-radius: 4px;
|
||
transition: all 0.2s;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.img-item:hover {
|
||
background: #bdc3c7;
|
||
}
|
||
|
||
.img-item.active {
|
||
background: #3498db;
|
||
color: white;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.img-item.annotated .status::after {
|
||
content: '✓';
|
||
color: #27ae60;
|
||
font-weight: bold;
|
||
font-size: 1.2em;
|
||
}
|
||
|
||
.img-item.active.annotated .status::after {
|
||
color: #2ecc71;
|
||
}
|
||
|
||
#controls {
|
||
margin-top: 15px;
|
||
display: flex;
|
||
gap: 15px;
|
||
align-items: center;
|
||
background: #34495e;
|
||
padding: 10px 20px;
|
||
border-radius: 8px;
|
||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||
}
|
||
|
||
button {
|
||
padding: 8px 20px;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
border: none;
|
||
border-radius: 4px;
|
||
background: #95a5a6;
|
||
color: white;
|
||
transition: background 0.2s;
|
||
}
|
||
|
||
button:hover { background: #7f8c8d; }
|
||
|
||
button.primary { background: #3498db; }
|
||
button.primary:hover { background: #2980b9; }
|
||
|
||
button.danger { background: #e74c3c; }
|
||
button.danger:hover { background: #c0392b; }
|
||
|
||
button.success { background: #27ae60; }
|
||
button.success:hover { background: #219150; }
|
||
|
||
#output-area {
|
||
width: 100%;
|
||
height: 120px;
|
||
margin-top: 15px;
|
||
position: relative;
|
||
}
|
||
|
||
#output {
|
||
width: 100%;
|
||
height: 100%;
|
||
font-family: 'Consolas', monospace;
|
||
padding: 10px;
|
||
box-sizing: border-box;
|
||
background: #ecf0f1;
|
||
color: #2c3e50;
|
||
border: none;
|
||
border-radius: 4px;
|
||
resize: none;
|
||
}
|
||
|
||
.instructions {
|
||
margin-top: 10px;
|
||
font-size: 0.9em;
|
||
color: #bdc3c7;
|
||
text-align: center;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div id="sidebar">
|
||
<h3>图片列表 (1-12)</h3>
|
||
<div id="file-list"></div>
|
||
</div>
|
||
|
||
<div id="main">
|
||
<div id="canvas-container">
|
||
<canvas id="canvas"></canvas>
|
||
</div>
|
||
|
||
<div class="instructions">
|
||
操作指南: 鼠标拖拽画圆 | A/← 上一张 | D/→ 下一张 | Del 清除当前 | 点击下方按钮导出
|
||
</div>
|
||
|
||
<div id="controls">
|
||
<span style="min-width: 100px;">当前: <strong id="current-file">--</strong></span>
|
||
<button onclick="prevImage()">上一张 (A)</button>
|
||
<button class="primary" onclick="nextImage()">下一张 (D)</button>
|
||
<button class="danger" onclick="clearAnnotation()">清除 (Del)</button>
|
||
<button class="success" onclick="exportData()">生成 JSON</button>
|
||
</div>
|
||
|
||
<div id="output-area">
|
||
<textarea id="output" readonly placeholder="点击“生成 JSON”按钮,此处将显示结果并自动复制到剪贴板..."></textarea>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// 配置
|
||
const imageFolder = './Images/';
|
||
const totalImages = 14;
|
||
const images = [];
|
||
for (let i = 1; i <= totalImages; i++) {
|
||
images.push(`${i}.jpg`);
|
||
}
|
||
|
||
// 状态
|
||
let currentIndex = 0;
|
||
const annotations = {}; // { "1.jpg": { x: 100, y: 100, r: 50 } }
|
||
|
||
// DOM 元素
|
||
const canvas = document.getElementById('canvas');
|
||
const ctx = canvas.getContext('2d');
|
||
const fileListEl = document.getElementById('file-list');
|
||
const canvasContainer = document.getElementById('canvas-container');
|
||
|
||
let imgObj = new Image();
|
||
let isDragging = false;
|
||
let startX, startY;
|
||
let currentMouseX, currentMouseY;
|
||
|
||
// 初始化侧边栏
|
||
function renderSidebar() {
|
||
fileListEl.innerHTML = '';
|
||
images.forEach((name, idx) => {
|
||
const div = document.createElement('div');
|
||
div.className = `img-item ${idx === currentIndex ? 'active' : ''} ${annotations[name] ? 'annotated' : ''}`;
|
||
div.innerHTML = `<span>${name}</span><span class="status"></span>`;
|
||
div.onclick = () => loadImage(idx);
|
||
fileListEl.appendChild(div);
|
||
});
|
||
|
||
// 滚动到当前项
|
||
const activeItem = fileListEl.querySelector('.active');
|
||
if (activeItem) {
|
||
activeItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||
}
|
||
}
|
||
|
||
function loadImage(index) {
|
||
if (index < 0) index = images.length - 1;
|
||
if (index >= images.length) index = 0;
|
||
currentIndex = index;
|
||
|
||
const filename = images[currentIndex];
|
||
document.getElementById('current-file').textContent = filename;
|
||
|
||
imgObj = new Image();
|
||
imgObj.onload = () => {
|
||
fitCanvas();
|
||
draw();
|
||
renderSidebar();
|
||
};
|
||
imgObj.onerror = () => {
|
||
alert(`无法加载图片: ${imageFolder + filename}\n请确保图片存在且路径正确。`);
|
||
};
|
||
imgObj.src = imageFolder + filename;
|
||
}
|
||
|
||
function fitCanvas() {
|
||
if (!imgObj.width) return;
|
||
|
||
const containerWidth = canvasContainer.clientWidth;
|
||
const containerHeight = canvasContainer.clientHeight;
|
||
|
||
// 计算缩放比例,保持长宽比,并留一点边距
|
||
const scale = Math.min(
|
||
(containerWidth - 20) / imgObj.width,
|
||
(containerHeight - 20) / imgObj.height
|
||
);
|
||
|
||
// 设置 Canvas 的显示大小
|
||
canvas.style.width = (imgObj.width * scale) + 'px';
|
||
canvas.style.height = (imgObj.height * scale) + 'px';
|
||
|
||
// 设置 Canvas 的实际分辨率 (等于图片原始分辨率)
|
||
canvas.width = imgObj.width;
|
||
canvas.height = imgObj.height;
|
||
}
|
||
|
||
function draw() {
|
||
// 清空画布
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
|
||
if (imgObj.width) {
|
||
ctx.drawImage(imgObj, 0, 0);
|
||
}
|
||
|
||
const name = images[currentIndex];
|
||
const data = annotations[name];
|
||
|
||
// 绘制已保存的标注 (绿色)
|
||
if (data) {
|
||
drawCircle(data.x, data.y, data.r, '#2ecc71', 4);
|
||
}
|
||
|
||
// 绘制正在拖拽的圆 (黄色)
|
||
if (isDragging) {
|
||
const dx = currentMouseX - startX;
|
||
const dy = currentMouseY - startY;
|
||
const r = Math.sqrt(dx*dx + dy*dy);
|
||
drawCircle(startX, startY, r, '#f1c40f', 2);
|
||
|
||
// 显示当前半径
|
||
ctx.fillStyle = '#f1c40f';
|
||
ctx.font = '20px Arial';
|
||
ctx.fillText(`R: ${Math.round(r)}`, currentMouseX + 10, currentMouseY + 10);
|
||
}
|
||
}
|
||
|
||
function drawCircle(x, y, r, color, lineWidth) {
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, r, 0, 2 * Math.PI);
|
||
ctx.strokeStyle = color;
|
||
ctx.lineWidth = lineWidth;
|
||
ctx.stroke();
|
||
|
||
// 绘制圆心
|
||
ctx.fillStyle = color;
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, lineWidth + 2, 0, 2 * Math.PI);
|
||
ctx.fill();
|
||
}
|
||
|
||
// 鼠标坐标映射 (从屏幕坐标到 Canvas 内部坐标)
|
||
function getMousePos(evt) {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const scaleX = canvas.width / rect.width;
|
||
const scaleY = canvas.height / rect.height;
|
||
return {
|
||
x: (evt.clientX - rect.left) * scaleX,
|
||
y: (evt.clientY - rect.top) * scaleY
|
||
};
|
||
}
|
||
|
||
// 事件监听
|
||
canvas.addEventListener('mousedown', (e) => {
|
||
const pos = getMousePos(e);
|
||
startX = pos.x;
|
||
startY = pos.y;
|
||
isDragging = true;
|
||
currentMouseX = pos.x;
|
||
currentMouseY = pos.y;
|
||
draw();
|
||
});
|
||
|
||
canvas.addEventListener('mousemove', (e) => {
|
||
if (!isDragging) return;
|
||
const pos = getMousePos(e);
|
||
currentMouseX = pos.x;
|
||
currentMouseY = pos.y;
|
||
draw();
|
||
});
|
||
|
||
canvas.addEventListener('mouseup', (e) => {
|
||
if (!isDragging) return;
|
||
isDragging = false;
|
||
const pos = getMousePos(e);
|
||
const dx = pos.x - startX;
|
||
const dy = pos.y - startY;
|
||
const r = Math.sqrt(dx*dx + dy*dy);
|
||
|
||
// 只有当半径大于 5 像素时才保存,防止误触
|
||
if (r > 5) {
|
||
annotations[images[currentIndex]] = {
|
||
x: Math.round(startX),
|
||
y: Math.round(startY),
|
||
r: Math.round(r)
|
||
};
|
||
}
|
||
draw();
|
||
renderSidebar();
|
||
});
|
||
|
||
// 窗口大小改变时重新适配
|
||
window.addEventListener('resize', () => {
|
||
if (imgObj.src) fitCanvas();
|
||
});
|
||
|
||
// 按钮功能
|
||
function prevImage() { loadImage(currentIndex - 1); }
|
||
function nextImage() { loadImage(currentIndex + 1); }
|
||
|
||
function clearAnnotation() {
|
||
if (annotations[images[currentIndex]]) {
|
||
delete annotations[images[currentIndex]];
|
||
draw();
|
||
renderSidebar();
|
||
}
|
||
}
|
||
|
||
function exportData() {
|
||
const result = [];
|
||
images.forEach(name => {
|
||
if (annotations[name]) {
|
||
result.push({
|
||
filename: name,
|
||
...annotations[name]
|
||
});
|
||
}
|
||
});
|
||
|
||
if (result.length === 0) {
|
||
alert("还没有任何标注数据!请先在图片上拖拽画圆。");
|
||
return;
|
||
}
|
||
|
||
const jsonStr = JSON.stringify(result, null, 2);
|
||
const outputEl = document.getElementById('output');
|
||
outputEl.value = jsonStr;
|
||
outputEl.select();
|
||
|
||
try {
|
||
document.execCommand('copy');
|
||
alert(`已生成 ${result.length} 条标注数据并复制到剪贴板!\n你可以直接粘贴到 Python 代码中使用。`);
|
||
} catch (err) {
|
||
alert('复制失败,请手动复制下方文本框中的内容。');
|
||
}
|
||
}
|
||
|
||
// 键盘快捷键
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.target.tagName === 'TEXTAREA') return;
|
||
|
||
switch(e.key) {
|
||
case 'a':
|
||
case 'A':
|
||
case 'ArrowLeft':
|
||
prevImage();
|
||
break;
|
||
case 'd':
|
||
case 'D':
|
||
case 'ArrowRight':
|
||
nextImage();
|
||
break;
|
||
case 'Delete':
|
||
case 'Backspace':
|
||
clearAnnotation();
|
||
break;
|
||
}
|
||
});
|
||
|
||
// 启动
|
||
loadImage(0);
|
||
|
||
</script>
|
||
</body>
|
||
</html> |