Files
m20_core_web/templates/manual_annotation.html
2025-10-26 15:34:58 +08:00

627 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>