feat: 优化标注页面
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -5,6 +5,8 @@ __pycache__/
|
|||||||
|
|
||||||
/static/received/left/
|
/static/received/left/
|
||||||
/static/received/right/
|
/static/received/right/
|
||||||
|
/static/received/left_marked/
|
||||||
|
/static/received/right_marked/
|
||||||
|
|
||||||
# 数据文件及数据库
|
# 数据文件及数据库
|
||||||
*.zip
|
*.zip
|
||||||
|
|||||||
224
cam_web.py
224
cam_web.py
@@ -141,8 +141,23 @@ def get_images_api():
|
|||||||
"""API: 获取图片列表 (JSON 格式)"""
|
"""API: 获取图片列表 (JSON 格式)"""
|
||||||
conn = sqlite3.connect(DATABASE_PATH)
|
conn = sqlite3.connect(DATABASE_PATH)
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
# 按时间倒序排列,包含标注图片字段
|
# 按时间倒序排列,包含标注图片字段和人工标注字段
|
||||||
cursor.execute("SELECT id, left_filename, right_filename, left_marked_filename, right_marked_filename, timestamp, metadata, comment, created_at FROM images ORDER BY timestamp DESC")
|
try:
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id, left_filename, right_filename, left_marked_filename, right_marked_filename,
|
||||||
|
timestamp, metadata, comment, created_at, manual_detections, is_manual_labeled
|
||||||
|
FROM images
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
""")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
# 如果字段不存在,使用基本查询
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id, left_filename, right_filename, left_marked_filename, right_marked_filename,
|
||||||
|
timestamp, metadata, comment, created_at, NULL as manual_detections, 0 as is_manual_labeled
|
||||||
|
FROM images
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
""")
|
||||||
|
|
||||||
rows = cursor.fetchall()
|
rows = cursor.fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -157,7 +172,9 @@ def get_images_api():
|
|||||||
"timestamp": row[5],
|
"timestamp": row[5],
|
||||||
"metadata": row[6],
|
"metadata": row[6],
|
||||||
"comment": row[7] or "", # 如果没有 comment 则显示空字符串
|
"comment": row[7] or "", # 如果没有 comment 则显示空字符串
|
||||||
"created_at": row[8]
|
"created_at": row[8],
|
||||||
|
"manual_detections": row[9] or "[]", # 人工标注检测框结果
|
||||||
|
"is_manual_labeled": bool(row[10]) if row[10] is not None else False # 是否已完成人工标注
|
||||||
})
|
})
|
||||||
return jsonify(images)
|
return jsonify(images)
|
||||||
|
|
||||||
@@ -323,31 +340,41 @@ def upload_images():
|
|||||||
right_marked_path = None
|
right_marked_path = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with VisionAPIClient() as client:
|
# with VisionAPIClient() as client:
|
||||||
# 处理左图
|
# # 处理左图
|
||||||
left_task_id = client.submit_task(image_id=1, image=img_left)
|
# left_task_id = client.submit_task(image_id=1, image=img_left)
|
||||||
# 处理右图
|
# # 处理右图
|
||||||
right_task_id = client.submit_task(image_id=2, image=img_right)
|
# right_task_id = client.submit_task(image_id=2, image=img_right)
|
||||||
|
|
||||||
# 等待任务完成
|
# # 等待任务完成
|
||||||
client.task_queue.join()
|
# client.task_queue.join()
|
||||||
|
|
||||||
# 获取处理结果
|
# # 获取处理结果
|
||||||
left_result = client.get_result(left_task_id)
|
# left_result = client.get_result(left_task_id)
|
||||||
right_result = client.get_result(right_task_id)
|
# right_result = client.get_result(right_task_id)
|
||||||
|
|
||||||
# 生成标注图片
|
# # 生成标注图片
|
||||||
if left_result and left_result.success:
|
# if left_result and left_result.success:
|
||||||
marked_left_img = draw_detections_on_image(img_left, left_result.detections)
|
# marked_left_img = draw_detections_on_image(img_left, left_result.detections)
|
||||||
left_marked_path = os.path.join(SAVE_PATH_LEFT_MARKED, left_marked_filename)
|
# left_marked_path = os.path.join(SAVE_PATH_LEFT_MARKED, left_marked_filename)
|
||||||
cv2.imwrite(left_marked_path, marked_left_img)
|
# cv2.imwrite(left_marked_path, marked_left_img)
|
||||||
logger.info(f"Saved marked left image: {left_marked_path}")
|
# logger.info(f"Saved marked left image: {left_marked_path}")
|
||||||
|
|
||||||
if right_result and right_result.success:
|
# if right_result and right_result.success:
|
||||||
marked_right_img = draw_detections_on_image(img_right, right_result.detections)
|
# marked_right_img = draw_detections_on_image(img_right, right_result.detections)
|
||||||
right_marked_path = os.path.join(SAVE_PATH_RIGHT_MARKED, right_marked_filename)
|
# right_marked_path = os.path.join(SAVE_PATH_RIGHT_MARKED, right_marked_filename)
|
||||||
cv2.imwrite(right_marked_path, marked_right_img)
|
# cv2.imwrite(right_marked_path, marked_right_img)
|
||||||
logger.info(f"Saved marked right image: {right_marked_path}")
|
# logger.info(f"Saved marked right image: {right_marked_path}")
|
||||||
|
|
||||||
|
# Debug 直接原图覆盖
|
||||||
|
left_marked_path = os.path.join(SAVE_PATH_LEFT_MARKED, left_marked_filename)
|
||||||
|
cv2.imwrite(left_marked_path, img_left)
|
||||||
|
logger.info(f"Saved marked left image: {left_marked_path}")
|
||||||
|
|
||||||
|
right_marked_path = os.path.join(SAVE_PATH_RIGHT_MARKED, right_marked_filename)
|
||||||
|
cv2.imwrite(right_marked_path, img_right)
|
||||||
|
logger.info(f"Saved marked right image: {right_marked_path}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error processing images with VisionAPIClient: {e}")
|
logger.error(f"Error processing images with VisionAPIClient: {e}")
|
||||||
# 即使处理失败,也继续保存原图
|
# 即使处理失败,也继续保存原图
|
||||||
@@ -418,6 +445,155 @@ def status():
|
|||||||
timestamp = latest_timestamp if has_frames else "N/A"
|
timestamp = latest_timestamp if has_frames else "N/A"
|
||||||
return jsonify({"has_frames": has_frames, "latest_timestamp": timestamp})
|
return jsonify({"has_frames": has_frames, "latest_timestamp": timestamp})
|
||||||
|
|
||||||
|
@app.route('/api/images/manual-detections', methods=['PUT'])
|
||||||
|
def update_manual_detections():
|
||||||
|
"""API: 更新图片的人工标注检测框结果,支持左右图像分别标注"""
|
||||||
|
data = request.json
|
||||||
|
image_id = data.get('id')
|
||||||
|
side = data.get('side', 'left') # 获取side参数,默认为左侧
|
||||||
|
detections = data.get('detections')
|
||||||
|
|
||||||
|
if not image_id or detections is None:
|
||||||
|
return jsonify({"error": "Image ID and detections are required"}), 400
|
||||||
|
|
||||||
|
# 验证检测数据格式
|
||||||
|
if not isinstance(detections, list):
|
||||||
|
return jsonify({"error": "Detections must be a list"}), 400
|
||||||
|
|
||||||
|
for detection in detections:
|
||||||
|
if not isinstance(detection, dict):
|
||||||
|
return jsonify({"error": "Each detection must be a dictionary"}), 400
|
||||||
|
|
||||||
|
required_keys = ['id', 'label', 'bbox']
|
||||||
|
for key in required_keys:
|
||||||
|
if key not in detection:
|
||||||
|
return jsonify({"error": f"Missing required key '{key}' in detection"}), 400
|
||||||
|
|
||||||
|
# 验证ID
|
||||||
|
if not isinstance(detection['id'], int) or detection['id'] not in [1, 2, 3, 4]:
|
||||||
|
return jsonify({"error": f"Invalid ID in detection: {detection['id']}"}), 400
|
||||||
|
|
||||||
|
# 验证标签
|
||||||
|
valid_labels = ['caisson', 'soldier', 'gun', 'number']
|
||||||
|
if detection['label'] not in valid_labels:
|
||||||
|
return jsonify({"error": f"Invalid label in detection: {detection['label']}"}), 400
|
||||||
|
|
||||||
|
# 验证边界框
|
||||||
|
bbox = detection['bbox']
|
||||||
|
if not isinstance(bbox, list) or len(bbox) != 4:
|
||||||
|
return jsonify({"error": f"Invalid bbox format in detection"}), 400
|
||||||
|
|
||||||
|
for coord in bbox:
|
||||||
|
if not isinstance(coord, int) or not (0 <= coord <= 999):
|
||||||
|
return jsonify({"error": f"Invalid bbox coordinate: {coord}"}), 400
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DATABASE_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("SELECT id FROM images WHERE id = ?", (image_id,))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"error": "Image not found"}), 404
|
||||||
|
|
||||||
|
# 添加人工标注字段(如果不存在)
|
||||||
|
try:
|
||||||
|
cursor.execute("""
|
||||||
|
ALTER TABLE images ADD COLUMN manual_detections_left TEXT
|
||||||
|
""")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute("""
|
||||||
|
ALTER TABLE images ADD COLUMN manual_detections_right TEXT
|
||||||
|
""")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute("""
|
||||||
|
ALTER TABLE images ADD COLUMN is_manual_labeled_left INTEGER DEFAULT 0
|
||||||
|
""")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute("""
|
||||||
|
ALTER TABLE images ADD COLUMN is_manual_labeled_right INTEGER DEFAULT 0
|
||||||
|
""")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 根据side参数更新对应的人工标注结果
|
||||||
|
if side == 'left':
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE images
|
||||||
|
SET manual_detections_left = ?, is_manual_labeled_left = 1
|
||||||
|
WHERE id = ?
|
||||||
|
""", (json.dumps(detections), image_id))
|
||||||
|
else: # side == 'right'
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE images
|
||||||
|
SET manual_detections_right = ?, is_manual_labeled_right = 1
|
||||||
|
WHERE id = ?
|
||||||
|
""", (json.dumps(detections), image_id))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# 重新生成标注图片
|
||||||
|
try:
|
||||||
|
regenerate_marked_images(image_id, detections, side)
|
||||||
|
return jsonify({"message": f"Manual detections for image {image_id} ({side}) updated successfully and marked images regenerated"})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"message": f"Manual detections for image {image_id} ({side}) updated successfully but failed to regenerate marked images: {str(e)}"}), 500
|
||||||
|
|
||||||
|
# 添加重新生成标注图片的函数
|
||||||
|
def regenerate_marked_images(image_id, detections, side):
|
||||||
|
"""重新生成标注图片,支持左右图像分别处理"""
|
||||||
|
conn = sqlite3.connect(DATABASE_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT left_filename, right_filename, left_marked_filename, right_marked_filename
|
||||||
|
FROM images
|
||||||
|
WHERE id = ?
|
||||||
|
""", (image_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
raise Exception("Image not found")
|
||||||
|
|
||||||
|
left_filename, right_filename, left_marked_filename, right_marked_filename = row
|
||||||
|
|
||||||
|
# 根据指定的side重新生成对应的标注图片
|
||||||
|
if side == 'left' and left_marked_filename:
|
||||||
|
left_path = os.path.join(SAVE_PATH_LEFT, left_filename)
|
||||||
|
left_marked_path = os.path.join(SAVE_PATH_LEFT_MARKED, left_marked_filename)
|
||||||
|
|
||||||
|
if os.path.exists(left_path):
|
||||||
|
img_left = cv2.imread(left_path)
|
||||||
|
if img_left is not None:
|
||||||
|
marked_left_img = draw_detections_on_image(img_left, detections)
|
||||||
|
cv2.imwrite(left_marked_path, marked_left_img)
|
||||||
|
|
||||||
|
elif side == 'right' and right_marked_filename:
|
||||||
|
right_path = os.path.join(SAVE_PATH_RIGHT, right_filename)
|
||||||
|
right_marked_path = os.path.join(SAVE_PATH_RIGHT_MARKED, right_marked_filename)
|
||||||
|
|
||||||
|
if os.path.exists(right_path):
|
||||||
|
img_right = cv2.imread(right_path)
|
||||||
|
if img_right is not None:
|
||||||
|
marked_right_img = draw_detections_on_image(img_right, detections)
|
||||||
|
cv2.imwrite(right_marked_path, marked_right_img)
|
||||||
|
|
||||||
|
# 添加人工标注页面路由
|
||||||
|
@app.route('/manual-annotation')
|
||||||
|
def manual_annotation():
|
||||||
|
"""人工标注页面"""
|
||||||
|
return render_template('manual_annotation.html')
|
||||||
|
|
||||||
|
|
||||||
# --- SocketIO 事件处理程序 ---
|
# --- SocketIO 事件处理程序 ---
|
||||||
@socketio.event
|
@socketio.event
|
||||||
def connect():
|
def connect():
|
||||||
|
|||||||
@@ -80,7 +80,6 @@
|
|||||||
|
|
||||||
<body>
|
<body>
|
||||||
<h1>Saved Images</h1>
|
<h1>Saved Images</h1>
|
||||||
<!-- 添加导航链接 -->
|
|
||||||
<p><a href="/">Back to Live Feed</a></p>
|
<p><a href="/">Back to Live Feed</a></p>
|
||||||
<div id="status">Loading images...</div>
|
<div id="status">Loading images...</div>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
@@ -88,7 +87,7 @@
|
|||||||
<button id="exportBtn" disabled>Export Selected</button>
|
<button id="exportBtn" disabled>Export Selected</button>
|
||||||
<button id="deleteSelectedBtn" disabled>Delete Selected</button>
|
<button id="deleteSelectedBtn" disabled>Delete Selected</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- 修改表格头部 -->
|
|
||||||
<table id="imagesTable">
|
<table id="imagesTable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -98,10 +97,8 @@
|
|||||||
style="display: inline-block; margin-left: 5px; cursor: pointer;">Select All</label>
|
style="display: inline-block; margin-left: 5px; cursor: pointer;">Select All</label>
|
||||||
</th>
|
</th>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Left Image</th>
|
<th>Left Marked Image</th>
|
||||||
<th>Right Image</th>
|
<th>Right Marked Image</th>
|
||||||
<th>Left Marked Image</th> <!-- 新增标注图片列 -->
|
|
||||||
<th>Right Marked Image</th> <!-- 新增标注图片列 -->
|
|
||||||
<th>Timestamp</th>
|
<th>Timestamp</th>
|
||||||
<th>Comment</th>
|
<th>Comment</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
@@ -114,9 +111,8 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
const socket = io('http://' + document.domain + ':' + location.port);
|
const socket = io('http://' + document.domain + ':' + location.port);
|
||||||
let allImages = []; // 存储所有图片数据
|
let allImages = [];
|
||||||
|
|
||||||
// 获取图片列表
|
|
||||||
async function loadImages() {
|
async function loadImages() {
|
||||||
document.getElementById('status').textContent = 'Loading images...';
|
document.getElementById('status').textContent = 'Loading images...';
|
||||||
try {
|
try {
|
||||||
@@ -125,72 +121,61 @@
|
|||||||
allImages = await response.json();
|
allImages = await response.json();
|
||||||
renderTable(allImages);
|
renderTable(allImages);
|
||||||
document.getElementById('status').textContent = `Loaded ${allImages.length} images.`;
|
document.getElementById('status').textContent = `Loaded ${allImages.length} images.`;
|
||||||
updateSelectAllCheckbox(); // 加载后更新全选框状态
|
updateSelectAllCheckbox();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading images:', error);
|
console.error('Error loading images:', error);
|
||||||
document.getElementById('status').textContent = 'Error loading images: ' + error.message;
|
document.getElementById('status').textContent = 'Error loading images: ' + error.message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 渲染表格
|
|
||||||
// 修改渲染表格的函数
|
|
||||||
function renderTable(images) {
|
function renderTable(images) {
|
||||||
const tbody = document.getElementById('imagesTableBody');
|
const tbody = document.getElementById('imagesTableBody');
|
||||||
tbody.innerHTML = ''; // 清空现有内容
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
images.forEach(image => {
|
images.forEach(image => {
|
||||||
const row = tbody.insertRow();
|
const row = tbody.insertRow();
|
||||||
|
|
||||||
|
// Checkbox column
|
||||||
row.insertCell(0).innerHTML = `<input type="checkbox" class="selectCheckbox" data-id="${image.id}">`;
|
row.insertCell(0).innerHTML = `<input type="checkbox" class="selectCheckbox" data-id="${image.id}">`;
|
||||||
|
|
||||||
|
// ID
|
||||||
row.insertCell(1).textContent = image.id;
|
row.insertCell(1).textContent = image.id;
|
||||||
|
|
||||||
// 原图
|
// Left Marked Image
|
||||||
const leftCell = row.insertCell(2);
|
const leftMarkedCell = row.insertCell(2);
|
||||||
const leftImg = document.createElement('img');
|
|
||||||
// 修改这里:使用 Flask 静态文件路径
|
|
||||||
leftImg.src = `/static/received/left/${image.left_filename}`;
|
|
||||||
leftImg.alt = "Left Image";
|
|
||||||
leftImg.className = 'image-preview';
|
|
||||||
leftImg.onerror = function () { this.src = 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="75" viewBox="0 0 100 75"><rect width="100" height="75" fill="%23eee"/><text x="50" y="40" font-family="Arial" font-size="12" fill="%23999" text-anchor="middle">No Image</text></svg>'; };
|
|
||||||
leftCell.appendChild(leftImg);
|
|
||||||
|
|
||||||
const rightCell = row.insertCell(3);
|
|
||||||
const rightImg = document.createElement('img');
|
|
||||||
// 修改这里:使用 Flask 静态文件路径
|
|
||||||
rightImg.src = `/static/received/right/${image.right_filename}`;
|
|
||||||
rightImg.alt = "Right Image";
|
|
||||||
rightImg.className = 'image-preview';
|
|
||||||
rightImg.onerror = function () { this.src = 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="75" viewBox="0 0 100 75"><rect width="100" height="75" fill="%23eee"/><text x="50" y="40" font-family="Arial" font-size="12" fill="%23999" text-anchor="middle">No Image</text></svg>'; };
|
|
||||||
rightCell.appendChild(rightImg);
|
|
||||||
|
|
||||||
// 标注图片
|
|
||||||
const leftMarkedCell = row.insertCell(4);
|
|
||||||
if (image.left_marked_filename) {
|
if (image.left_marked_filename) {
|
||||||
const leftMarkedImg = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
leftMarkedImg.src = `/static/received/left_marked/${image.left_marked_filename}`;
|
img.src = `/static/received/left_marked/${image.left_marked_filename}`;
|
||||||
leftMarkedImg.alt = "Left Marked Image";
|
img.alt = "Left Marked Image";
|
||||||
leftMarkedImg.className = 'image-preview';
|
img.className = 'image-preview';
|
||||||
leftMarkedImg.onerror = function () { this.src = 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="75" viewBox="0 0 100 75"><rect width="100" height="75" fill="%23eee"/><text x="50" y="40" font-family="Arial" font-size="12" fill="%23999" text-anchor="middle">No Image</text></svg>'; };
|
img.onerror = function () {
|
||||||
leftMarkedCell.appendChild(leftMarkedImg);
|
this.src = 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="75" viewBox="0 0 100 75"><rect width="100" height="75" fill="%23eee"/><text x="50" y="40" font-family="Arial" font-size="12" fill="%23999" text-anchor="middle">No Image</text></svg>';
|
||||||
|
};
|
||||||
|
leftMarkedCell.appendChild(img);
|
||||||
} else {
|
} else {
|
||||||
leftMarkedCell.textContent = "N/A";
|
leftMarkedCell.textContent = "N/A";
|
||||||
}
|
}
|
||||||
|
|
||||||
const rightMarkedCell = row.insertCell(5);
|
// Right Marked Image
|
||||||
|
const rightMarkedCell = row.insertCell(3);
|
||||||
if (image.right_marked_filename) {
|
if (image.right_marked_filename) {
|
||||||
const rightMarkedImg = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
rightMarkedImg.src = `/static/received/right_marked/${image.right_marked_filename}`;
|
img.src = `/static/received/right_marked/${image.right_marked_filename}`;
|
||||||
rightMarkedImg.alt = "Right Marked Image";
|
img.alt = "Right Marked Image";
|
||||||
rightMarkedImg.className = 'image-preview';
|
img.className = 'image-preview';
|
||||||
rightMarkedImg.onerror = function () { this.src = 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="75" viewBox="0 0 100 75"><rect width="100" height="75" fill="%23eee"/><text x="50" y="40" font-family="Arial" font-size="12" fill="%23999" text-anchor="middle">No Image</text></svg>'; };
|
img.onerror = function () {
|
||||||
rightMarkedCell.appendChild(rightMarkedImg);
|
this.src = 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="75" viewBox="0 0 100 75"><rect width="100" height="75" fill="%23eee"/><text x="50" y="40" font-family="Arial" font-size="12" fill="%23999" text-anchor="middle">No Image</text></svg>';
|
||||||
|
};
|
||||||
|
rightMarkedCell.appendChild(img);
|
||||||
} else {
|
} else {
|
||||||
rightMarkedCell.textContent = "N/A";
|
rightMarkedCell.textContent = "N/A";
|
||||||
}
|
}
|
||||||
|
|
||||||
row.insertCell(6).textContent = new Date(image.timestamp * 1000).toISOString();
|
// Timestamp
|
||||||
|
row.insertCell(4).textContent = new Date(image.timestamp * 1000).toISOString();
|
||||||
|
|
||||||
// 添加可编辑的 comment 单元格
|
// Comment
|
||||||
const commentCell = row.insertCell(7);
|
const commentCell = row.insertCell(5);
|
||||||
const commentInput = document.createElement('input');
|
const commentInput = document.createElement('input');
|
||||||
commentInput.type = 'text';
|
commentInput.type = 'text';
|
||||||
commentInput.value = image.comment || '';
|
commentInput.value = image.comment || '';
|
||||||
@@ -198,15 +183,19 @@
|
|||||||
commentInput.className = 'comment-input';
|
commentInput.className = 'comment-input';
|
||||||
commentInput.style.width = '100%';
|
commentInput.style.width = '100%';
|
||||||
commentInput.addEventListener('change', function () {
|
commentInput.addEventListener('change', function () {
|
||||||
updateComment(image.id, this.value);
|
updateComment(image.id, this.value);
|
||||||
});
|
});
|
||||||
commentCell.appendChild(commentInput);
|
commentCell.appendChild(commentInput);
|
||||||
|
|
||||||
row.insertCell(8).innerHTML = `<button onclick="deleteImage(${image.id})">Delete</button>`;
|
// Actions
|
||||||
|
row.insertCell(6).innerHTML = `
|
||||||
|
<button onclick="deleteImage(${image.id})">Delete</button>
|
||||||
|
<button onclick="window.open('/manual-annotation?id=${image.id}&side=left', '_blank')">LBL</button>
|
||||||
|
<button onclick="window.open('/manual-annotation?id=${image.id}&side=right', '_blank')">LBR</button>
|
||||||
|
`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加更新 comment 的函数
|
|
||||||
async function updateComment(id, comment) {
|
async function updateComment(id, comment) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/images/comment', {
|
const response = await fetch('/api/images/comment', {
|
||||||
@@ -225,7 +214,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除图片
|
|
||||||
async function deleteImage(id) {
|
async function deleteImage(id) {
|
||||||
if (!confirm(`Are you sure you want to delete image ID ${id}?`)) return;
|
if (!confirm(`Are you sure you want to delete image ID ${id}?`)) return;
|
||||||
|
|
||||||
@@ -239,10 +227,9 @@
|
|||||||
const errorData = await response.json().catch(() => ({ error: response.statusText }));
|
const errorData = await response.json().catch(() => ({ error: response.statusText }));
|
||||||
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
|
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
// 从本地数组中移除
|
|
||||||
allImages = allImages.filter(img => img.id !== id);
|
allImages = allImages.filter(img => img.id !== id);
|
||||||
renderTable(allImages);
|
renderTable(allImages);
|
||||||
updateSelectAllCheckbox(); // 删除后更新全选框状态
|
updateSelectAllCheckbox();
|
||||||
document.getElementById('status').textContent = `Image ID ${id} deleted.`;
|
document.getElementById('status').textContent = `Image ID ${id} deleted.`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting image:', error);
|
console.error('Error deleting image:', error);
|
||||||
@@ -250,13 +237,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取选中的图片 ID
|
|
||||||
function getSelectedIds() {
|
function getSelectedIds() {
|
||||||
const checkboxes = document.querySelectorAll('.selectCheckbox:checked');
|
const checkboxes = document.querySelectorAll('.selectCheckbox:checked');
|
||||||
return Array.from(checkboxes).map(cb => parseInt(cb.dataset.id));
|
return Array.from(checkboxes).map(cb => parseInt(cb.dataset.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新全选复选框的状态
|
|
||||||
function updateSelectAllCheckbox() {
|
function updateSelectAllCheckbox() {
|
||||||
const checkboxes = document.querySelectorAll('.selectCheckbox');
|
const checkboxes = document.querySelectorAll('.selectCheckbox');
|
||||||
const allSelected = checkboxes.length > 0 && checkboxes.length === document.querySelectorAll('.selectCheckbox:checked').length;
|
const allSelected = checkboxes.length > 0 && checkboxes.length === document.querySelectorAll('.selectCheckbox:checked').length;
|
||||||
@@ -264,17 +249,14 @@
|
|||||||
updateExportDeleteButtons();
|
updateExportDeleteButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新导出和删除按钮的启用状态
|
|
||||||
function updateExportDeleteButtons() {
|
function updateExportDeleteButtons() {
|
||||||
const selectedCount = getSelectedIds().length;
|
const selectedCount = getSelectedIds().length;
|
||||||
document.getElementById('exportBtn').disabled = selectedCount === 0;
|
document.getElementById('exportBtn').disabled = selectedCount === 0;
|
||||||
document.getElementById('deleteSelectedBtn').disabled = selectedCount === 0;
|
document.getElementById('deleteSelectedBtn').disabled = selectedCount === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 绑定事件
|
|
||||||
document.getElementById('refreshBtn').addEventListener('click', loadImages);
|
document.getElementById('refreshBtn').addEventListener('click', loadImages);
|
||||||
|
|
||||||
// --- 修改:使用 fetch API 发起导出请求 ---
|
|
||||||
document.getElementById('exportBtn').addEventListener('click', async function () {
|
document.getElementById('exportBtn').addEventListener('click', async function () {
|
||||||
const selectedIds = getSelectedIds();
|
const selectedIds = getSelectedIds();
|
||||||
if (selectedIds.length === 0) {
|
if (selectedIds.length === 0) {
|
||||||
@@ -283,13 +265,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 使用 fetch API 发起 POST 请求
|
|
||||||
const response = await fetch('/api/images/export', {
|
const response = await fetch('/api/images/export', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json', // 设置正确的 Content-Type
|
body: JSON.stringify({ ids: selectedIds })
|
||||||
},
|
|
||||||
body: JSON.stringify({ ids: selectedIds }) // 发送 JSON 格式的请求体
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -297,18 +276,16 @@
|
|||||||
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
|
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果响应是成功的,浏览器会接收到 ZIP 文件,通常会自动触发下载
|
const blob = await response.blob();
|
||||||
// 我们可以通过创建一个隐藏的链接来模拟下载
|
const url = window.URL.createObjectURL(blob);
|
||||||
const blob = await response.blob(); // 获取响应的 blob 数据
|
const a = document.createElement('a');
|
||||||
const url = window.URL.createObjectURL(blob); // 创建一个 URL 对象
|
|
||||||
const a = document.createElement('a'); // 创建一个隐藏的 <a> 标签
|
|
||||||
a.style.display = 'none';
|
a.style.display = 'none';
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = 'exported_images.zip'; // 设置下载的文件名
|
a.download = 'exported_images.zip';
|
||||||
document.body.appendChild(a); // 将 <a> 标签添加到页面
|
document.body.appendChild(a);
|
||||||
a.click(); // 模拟点击 <a> 标签
|
a.click();
|
||||||
window.URL.revokeObjectURL(url); // 释放 URL 对象
|
window.URL.revokeObjectURL(url);
|
||||||
document.body.removeChild(a); // 移除 <a> 标签
|
document.body.removeChild(a);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error exporting images:', error);
|
console.error('Error exporting images:', error);
|
||||||
document.getElementById('status').textContent = 'Error exporting images: ' + error.message;
|
document.getElementById('status').textContent = 'Error exporting images: ' + error.message;
|
||||||
@@ -324,7 +301,6 @@
|
|||||||
if (!confirm(`Are you sure you want to delete ${selectedIds.length} selected images?`)) return;
|
if (!confirm(`Are you sure you want to delete ${selectedIds.length} selected images?`)) return;
|
||||||
|
|
||||||
for (const id of selectedIds) {
|
for (const id of selectedIds) {
|
||||||
// 逐个调用删除 API,或者可以优化为批量删除 API
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/images', {
|
const response = await fetch('/api/images', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
@@ -338,14 +314,12 @@
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error deleting image ID ${id}:`, error);
|
console.error(`Error deleting image ID ${id}:`, error);
|
||||||
document.getElementById('status').textContent = `Error deleting image ID ${id}: ${error.message}`;
|
document.getElementById('status').textContent = `Error deleting image ID ${id}: ${error.message}`;
|
||||||
return; // 停止删除后续图片
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 成功后刷新列表
|
|
||||||
loadImages();
|
loadImages();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 全选复选框事件监听
|
|
||||||
document.getElementById('selectAllCheckbox').addEventListener('change', function () {
|
document.getElementById('selectAllCheckbox').addEventListener('change', function () {
|
||||||
const isChecked = this.checked;
|
const isChecked = this.checked;
|
||||||
const checkboxes = document.querySelectorAll('.selectCheckbox');
|
const checkboxes = document.querySelectorAll('.selectCheckbox');
|
||||||
@@ -355,16 +329,13 @@
|
|||||||
updateExportDeleteButtons();
|
updateExportDeleteButtons();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听单个复选框变化以更新全选框和按钮状态
|
|
||||||
document.getElementById('imagesTable').addEventListener('change', function (e) {
|
document.getElementById('imagesTable').addEventListener('change', function (e) {
|
||||||
if (e.target.classList.contains('selectCheckbox')) {
|
if (e.target.classList.contains('selectCheckbox')) {
|
||||||
updateSelectAllCheckbox();
|
updateSelectAllCheckbox();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 页面加载时获取图片列表
|
|
||||||
loadImages();
|
loadImages();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
627
templates/manual_annotation.html
Normal file
627
templates/manual_annotation.html
Normal file
@@ -0,0 +1,627 @@
|
|||||||
|
<!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>
|
||||||
Reference in New Issue
Block a user