Files
m20_core_web/templates/manual_annotation.html

627 lines
22 KiB
HTML
Raw Normal View History

2025-10-26 15:34:58 +08:00
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>人工标注</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/5.3.1/fabric.min.js"></script>
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
background-color: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
background-color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid #eee;
}
.image-container {
display: flex;
gap: 20px;
margin-bottom: 20px;
}
.canvas-wrapper {
flex: 1;
border: 1px solid #ddd;
border-radius: 4px;
overflow: hidden;
}
#canvas {
border: none;
display: block;
}
.controls {
margin: 20px 0;
padding: 15px;
background-color: #f8f9fa;
border-radius: 4px;
}
.label-buttons {
display: flex;
gap: 10px;
margin: 15px 0;
flex-wrap: wrap;
}
.label-btn {
padding: 10px 15px;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
transition: all 0.2s;
}
.label-btn:hover {
transform: translateY(-2px);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
}
.label-btn.active {
transform: translateY(-2px);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
}
.btn-positive {
background-color: #28a745;
color: white;
}
.btn-negative {
background-color: #dc3545;
color: white;
}
.btn-neutral {
background-color: #6c757d;
color: white;
}
.btn-unclear {
background-color: #ffc107;
color: black;
}
.btn-primary {
padding: 10px 20px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
margin-right: 10px;
}
.btn-primary:hover {
background-color: #0056b3;
}
.btn-secondary {
padding: 10px 20px;
background-color: #6c757d;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
text-decoration: none;
display: inline-block;
}
.btn-secondary:hover {
background-color: #545b62;
}
.detection-list {
margin-top: 20px;
max-height: 300px;
overflow-y: auto;
}
.detection-item {
padding: 10px;
border: 1px solid #ddd;
margin-bottom: 10px;
border-radius: 4px;
background-color: #f8f9fa;
}
.detection-item.active {
background-color: #e9f7ef;
border-color: #28a745;
}
.info-panel {
background-color: #e9f7ef;
padding: 15px;
border-radius: 4px;
margin-bottom: 20px;
}
.instructions {
background-color: #fff3cd;
padding: 15px;
border-radius: 4px;
margin-bottom: 20px;
}
.status {
padding: 10px;
background-color: #d1ecf1;
border-radius: 4px;
margin: 10px 0;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>人工标注</h1>
<a href="/list" class="btn-secondary">返回图片列表</a>
</div>
<div class="info-panel">
<h3>图像信息</h3>
<p><strong>图像ID:</strong> <span id="image-id"></span></p>
<p><strong>时间戳:</strong> <span id="image-timestamp"></span></p>
<p><strong>备注:</strong> <span id="image-comment"></span></p>
</div>
<!-- <div class="instructions">
<h3>操作说明</h3>
<ul>
<li>选择一个目标类别</li>
<li>在图像上按住鼠标左键拖拽绘制矩形框</li>
<li>点击已绘制的框可选中并编辑</li>
<li>按 Delete 键删除选中的框</li>
<li>完成标注后点击"保存标注结果"</li>
</ul>
</div> -->
<div class="image-container">
<div class="canvas-wrapper">
<canvas id="canvas"></canvas>
</div>
</div>
<div class="controls">
<h3>目标类别</h3>
<div class="label-buttons">
<button class="label-btn btn-positive" data-id="1" data-label="caisson">弹药箱 (1)</button>
<button class="label-btn btn-negative" data-id="2" data-label="soldier">士兵 (2)</button>
<button class="label-btn btn-neutral" data-id="3" data-label="gun">枪支 (3)</button>
<button class="label-btn btn-unclear" data-id="4" data-label="number">数字牌 (4)</button>
</div>
<div class="status">
<p>当前状态: <span id="status-text">请选择一个目标类别开始标注</span></p>
<p>当前选中类别: <span id="selected-label"></span></p>
</div>
<button class="btn-primary" id="save-btn">保存标注结果</button>
<button class="btn-primary" id="clear-btn">清空所有标注</button>
</div>
<div class="detection-list">
<h3>已标注目标 (<span id="detection-count">0</span>)</h3>
<div id="detections-container"></div>
</div>
</div>
<script>
// 从URL参数获取图像ID
const urlParams = new URLSearchParams(window.location.search);
const imageId = urlParams.get('id');
const side = urlParams.get('side') || 'left';
// 全局变量
let canvas = null;
let currentImageData = null;
let selectedLabel = null;
let detections = []; // 存储所有检测框
let isDrawing = false;
let startX, startY;
let rect = null;
// 页面加载时初始化
document.addEventListener('DOMContentLoaded', function () {
if (!imageId) {
alert('缺少图像ID参数');
window.location.href = '/list';
return;
}
window.currentSide = side;
document.querySelector('h1').textContent = `人工标注 (${side === 'left' ? '左侧' : '右侧'}图像)`;
// 初始化Fabric.js画布
canvas = new fabric.Canvas('canvas', {
selection: false,
preserveObjectStacking: true
});
// 设置画布事件
setupCanvasEvents();
// 加载图像数据
loadImageData(imageId, side);
// 绑定按钮事件
document.getElementById('save-btn').addEventListener('click', saveManualAnnotations);
document.getElementById('clear-btn').addEventListener('click', clearAllAnnotations);
// 绑定标签按钮事件
document.querySelectorAll('.label-btn').forEach(button => {
button.addEventListener('click', function () {
// 移除其他按钮的激活状态
document.querySelectorAll('.label-btn').forEach(btn => {
btn.classList.remove('active');
});
// 添加当前按钮的激活状态
this.classList.add('active');
// 记录选中的标签
selectedLabel = {
id: parseInt(this.getAttribute('data-id')),
label: this.getAttribute('data-label')
};
// 更新显示
document.getElementById('selected-label').textContent =
`${selectedLabel.label} (${selectedLabel.id})`;
document.getElementById('status-text').textContent =
`已选择 ${selectedLabel.label},现在可以在图像上绘制标注框`;
});
});
// 键盘事件处理
document.addEventListener('keydown', function (e) {
if (e.key === 'Delete' || e.key === 'Backspace') {
const activeObject = canvas.getActiveObject();
if (activeObject && activeObject.detectionId !== undefined) {
// 删除检测框
const detectionId = activeObject.detectionId;
detections = detections.filter(d => d.id !== detectionId);
canvas.remove(activeObject);
updateDetectionsList();
document.getElementById('detection-count').textContent = detections.length;
}
}
});
});
// 设置画布事件
function setupCanvasEvents() {
canvas.on('mouse:down', function (options) {
if (!selectedLabel) {
alert('请先选择一个目标类别');
return;
}
const pointer = canvas.getPointer(options.e);
startX = pointer.x;
startY = pointer.y;
isDrawing = true;
// 创建矩形对象
rect = new fabric.Rect({
left: startX,
top: startY,
width: 0,
height: 0,
fill: 'rgba(0,0,0,0)',
stroke: getColorForLabel(selectedLabel.id),
strokeWidth: 2,
selectable: true,
hasControls: true,
hasBorders: true
});
canvas.add(rect);
});
canvas.on('mouse:move', function (options) {
if (!isDrawing) return;
const pointer = canvas.getPointer(options.e);
const width = pointer.x - startX;
const height = pointer.y - startY;
rect.set({
width: width,
height: height
});
canvas.renderAll();
});
canvas.on('mouse:up', function () {
if (!isDrawing) return;
isDrawing = false;
// 确保矩形有最小尺寸
if (Math.abs(rect.width) < 5 || Math.abs(rect.height) < 5) {
canvas.remove(rect);
return;
}
// 标准化矩形(确保宽度和高度为正)
if (rect.width < 0) {
rect.set({ left: rect.left + rect.width, width: Math.abs(rect.width) });
}
if (rect.height < 0) {
rect.set({ top: rect.top + rect.height, height: Math.abs(rect.height) });
}
// 添加标签文本
const text = new fabric.Text(`${selectedLabel.label} (${selectedLabel.id})`, {
left: rect.left,
top: rect.top - 15,
fontSize: 14,
fill: getColorForLabel(selectedLabel.id),
selectable: false
});
canvas.add(text);
// 为矩形添加唯一ID并关联文本
const detectionId = Date.now(); // 简单的唯一ID生成
rect.set({
detectionId: detectionId,
labelText: text
});
// 保存检测信息
const detection = {
id: detectionId,
categoryId: selectedLabel.id,
label: selectedLabel.label,
bbox: [
Math.round(rect.left * 999 / canvas.width),
Math.round(rect.top * 999 / canvas.height),
Math.round((rect.left + rect.width) * 999 / canvas.width),
Math.round((rect.top + rect.height) * 999 / canvas.height)
]
};
detections.push(detection);
updateDetectionsList();
document.getElementById('detection-count').textContent = detections.length;
// 选中新创建的矩形
canvas.setActiveObject(rect);
});
// 对象修改事件
canvas.on('object:modified', function (options) {
const modifiedObject = options.target;
if (modifiedObject.detectionId !== undefined) {
// 更新对应的检测信息
const detection = detections.find(d => d.id === modifiedObject.detectionId);
if (detection) {
detection.bbox = [
Math.round(modifiedObject.left * 999 / canvas.width),
Math.round(modifiedObject.top * 999 / canvas.height),
Math.round((modifiedObject.left + modifiedObject.width) * 999 / canvas.width),
Math.round((modifiedObject.top + modifiedObject.height) * 999 / canvas.height)
];
updateDetectionsList();
}
// 更新标签文本位置
if (modifiedObject.labelText) {
modifiedObject.labelText.set({
left: modifiedObject.left,
top: modifiedObject.top - 15
});
canvas.renderAll();
}
}
});
}
// 根据标签ID获取颜色
function getColorForLabel(id) {
const colorMap = {
1: '#28a745', // 绿色 - 弹药箱
2: '#dc3545', // 红色 - 士兵
3: '#6c757d', // 灰色 - 枪支
4: '#ffc107' // 黄色 - 数字牌
};
return colorMap[id] || '#000000';
}
// 加载图像数据
async function loadImageData(id, side) {
try {
const response = await fetch('/api/images');
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const images = await response.json();
// 查找指定ID的图像
const image = images.find(img => img.id == id);
if (!image) {
throw new Error('未找到指定的图像');
}
currentImageData = image;
// 显示图像信息
document.getElementById('image-id').textContent = image.id;
document.getElementById('image-timestamp').textContent = new Date(image.timestamp * 1000).toISOString();
document.getElementById('image-comment').textContent = image.comment || '无';
// 根据side参数决定加载哪一侧的图像
const imagePath = side === 'left'
? `/static/received/left/${image.left_filename}`
: `/static/received/right/${image.right_filename}`;
const imageInfo = side === 'left'
? `左摄像头图像: ${image.left_filename}`
: `右摄像头图像: ${image.right_filename}`;
// 更新图像信息显示
document.querySelector('.info-panel h3').textContent = imageInfo;
// 加载图像到画布
fabric.Image.fromURL(imagePath, function (img) {
// 设置画布尺寸
const maxWidth = 800;
const maxHeight = 600;
let width = img.width;
let height = img.height;
// 按比例缩放
if (width > maxWidth || height > maxHeight) {
const ratio = Math.min(maxWidth / width, maxHeight / height);
width = width * ratio;
height = height * ratio;
}
canvas.setDimensions({ width: width, height: height });
img.set({ selectable: false, evented: false });
img.scaleToWidth(width);
img.scaleToHeight(height);
canvas.setBackgroundImage(img, canvas.renderAll.bind(canvas));
// 如果已有机器标注结果,则加载显示
// loadMachineDetections(image, side);
});
} catch (error) {
console.error('Error loading image data:', error);
alert('加载图像数据失败: ' + error.message);
}
}
// 加载机器标注结果
function loadMachineDetections(image, side) {
// if (image.manual_detections && image.manual_detections !== "[]") {
// try {
// const manualDetections = JSON.parse(image.manual_detections);
// if (Array.isArray(manualDetections) && manualDetections.length > 0) {
// // 加载人工标注结果到画布
// loadDetectionsToCanvas(manualDetections);
// document.getElementById('status-text').textContent =
// '已加载人工标注结果,可继续编辑';
// return;
// }
// } catch (e) {
// console.error('Error parsing manual detections:', e);
// }
// }
}
// 更新检测列表显示
function updateDetectionsList() {
const container = document.getElementById('detections-container');
container.innerHTML = '';
if (detections.length === 0) {
container.innerHTML = '<p>暂无标注目标</p>';
return;
}
detections.forEach(detection => {
const item = document.createElement('div');
item.className = 'detection-item';
item.innerHTML = `
<strong>${detection.label} (${detection.categoryId})</strong><br>
边界框: [${detection.bbox.join(', ')}]
`;
container.appendChild(item);
});
}
// 清空所有标注
function clearAllAnnotations() {
if (confirm('确定要清空所有标注吗?')) {
// 删除画布上的所有对象(除了背景图像)
const objects = canvas.getObjects();
objects.forEach(obj => {
if (obj !== canvas.backgroundImage) {
canvas.remove(obj);
}
});
// 清空检测数据
detections = [];
updateDetectionsList();
document.getElementById('detection-count').textContent = '0';
}
}
// 保存人工标注结果
async function saveManualAnnotations() {
if (!currentImageData) {
alert('图像数据未加载完成');
return;
}
// 构造与机器标注格式一致的JSON数据
const annotationData = {
detections: detections.map(d => ({
id: d.categoryId,
label: d.label,
bbox: d.bbox
}))
};
try {
// 发送请求更新标注结果包含side参数
const response = await fetch('/api/images/manual-detections', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
id: currentImageData.id,
side: window.currentSide, // 添加side参数
detections: annotationData.detections
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
}
const result = await response.json();
alert('人工标注结果已保存');
// 返回图片列表页面
window.location.href = '/list';
} catch (error) {
console.error('Error saving manual annotations:', error);
alert('保存失败: ' + error.message);
}
}
</script>
</body>
</html>