旋转OBB数据集标注查看器
📖 简介
这是一个用于可视化检查旋转边界框(Oriented Bounding Box, OBB)数据集标注的网页工具。可以帮助你快速浏览图片和对应的标注框,检查标注质量是否正确。
✨ 主要功能
- 📁 本地文件加载:直接选择本地文件夹,无需上传到服务器
- 🎨 彩色标注显示:不同类别用不同颜色的边界框显示
- 🔄 旋转框支持:支持YOLO格式的旋转边界框(8个坐标点)
- ⚙️ 自定义类别:可以根据自己的数据集自定义类别名称
- ⌨️ 快捷键导航:使用左右箭头键快速浏览图片
- 📊 详细信息:显示图片尺寸、目标数量等信息
🚀 使用方法
1. 打开工具
直接双击 dataset_viewer.html 文件,在浏览器中打开
2. 设置类别
在"类别设置"文本框中输入你的类别,每行一个,格式:
0:ore-oil
1:bulk-cargo
2:Fishing
3:LawEnforce
4:Dredger
5:Container
点击"更新类别"按钮应用设置
3. 选择文件夹
- 点击"📁 选择图片文件夹",选择包含图片的文件夹(如
images/test) - 点击"📄 选择标签文件夹",选择对应的标签文件夹(如
labels/test)
4. 浏览数据集
- 使用"上一张"/"下一张"按钮切换图片
- 或使用键盘左右箭头键快速浏览
- 或在下拉菜单中直接选择要查看的图片
📝 标注格式说明
支持YOLO格式的旋转边界框标注,每行格式为:
class_id x1 y1 x2 y2 x3 y3 x4 y4
其中:
class_id:类别ID(整数,从0开始)x1 y1 x2 y2 x3 y3 x4 y4:旋转框的4个角点坐标(归一化值,范围0-1)
示例:
1 0.6845703125 0.8017578125 0.6943359375 0.837890625 0.6201171875 0.8583984375 0.6103515625 0.822265625
🎯 适用场景
- ✅ 检查数据集标注质量
- ✅ 验证标注框位置是否正确
- ✅ 查看类别分布情况
- ✅ 发现标注错误或遗漏
- ✅ 数据集预览和展示
💡 技巧
- 快速浏览:使用键盘左右箭头键比鼠标点击更快
- 自定义类别:可以随时修改类别名称,适配不同数据集
- 批量检查:按顺序浏览所有图片,确保标注一致性
- 颜色区分:不同类别自动分配不同颜色,便于识别
🔧 技术特点
- 纯前端实现,无需后端服务器
- 使用HTML5 Canvas绘制标注框
- 支持本地文件系统访问(File API)
- 响应式设计,适配不同屏幕尺寸
📋 系统要求
- 现代浏览器(Chrome、Edge、Firefox等)
- 支持HTML5和JavaScript
- 无需安装任何依赖
🐛 常见问题
Q: 为什么看不到标注框?
A: 请确保:
- 图片和标签文件名对应(如 L532.png 对应 L532.txt)
- 标签文件格式正确(每行9个数字)
- 已经点击"更新类别"按钮
Q: 可以用于其他格式的数据集吗?
A: 目前只支持YOLO旋转框格式(8个坐标点)。如需其他格式,需要修改代码。
Q: 能否保存修改后的标注?
A: 这是一个只读查看工具,不支持编辑和保存功能。
📄 许可
本工具为开源项目,可自由使用和修改。
代码
bash
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>旋转OBB数据集标注查看器</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Arial, sans-serif;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
padding: 20px;
}
h1 {
color: #333;
margin-bottom: 20px;
text-align: center;
}
.controls {
display: flex;
gap: 15px;
margin-bottom: 20px;
flex-wrap: wrap;
align-items: center;
padding: 15px;
background: #f8f9fa;
border-radius: 6px;
}
.control-group {
display: flex;
align-items: center;
gap: 8px;
}
label {
font-weight: 600;
color: #555;
}
select, input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
button {
padding: 8px 16px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
button:hover {
background: #0056b3;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
.file-input {
display: none;
}
.file-button {
padding: 8px 16px;
background: #28a745;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.file-button:hover {
background: #218838;
}
.viewer {
display: flex;
gap: 20px;
}
.canvas-container {
flex: 1;
position: relative;
background: #000;
border-radius: 6px;
overflow: hidden;
min-height: 400px;
display: flex;
align-items: center;
justify-content: center;
}
canvas {
display: block;
max-width: 100%;
height: auto;
}
.info-panel {
width: 300px;
background: #f8f9fa;
padding: 15px;
border-radius: 6px;
overflow-y: auto;
max-height: 600px;
}
.info-section {
margin-bottom: 15px;
}
.info-section h3 {
color: #333;
font-size: 16px;
margin-bottom: 8px;
border-bottom: 2px solid #007bff;
padding-bottom: 5px;
}
.info-item {
padding: 6px 0;
color: #666;
font-size: 14px;
}
.class-legend {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 0;
}
.color-box {
width: 20px;
height: 20px;
border-radius: 3px;
border: 1px solid #333;
}
.navigation {
display: flex;
gap: 10px;
align-items: center;
}
.error {
color: #dc3545;
padding: 10px;
background: #f8d7da;
border-radius: 4px;
margin: 10px 0;
}
.success {
color: #155724;
padding: 10px;
background: #d4edda;
border-radius: 4px;
margin: 10px 0;
}
.instruction {
background: #e7f3ff;
padding: 15px;
border-radius: 6px;
margin-bottom: 20px;
border-left: 4px solid #007bff;
}
.instruction h3 {
color: #007bff;
margin-bottom: 8px;
}
.instruction ol {
margin-left: 20px;
color: #555;
}
.instruction li {
margin: 5px 0;
}
</style>
</head>
<body>
<div class="container">
<h1>� 旋转OBDB数据集标注查看器</h1>
<div class="instruction">
<h3>📖 使用说明:</h3>
<ol>
<li>点击"选择图片文件夹",选择包含图片的文件夹</li>
<li>点击"选择标签文件夹",选择对应的标签文件夹</li>
<li>(可选)修改类别名称,点击"更新类别"</li>
<li>使用左右箭头键或按钮浏览数据集</li>
</ol>
</div>
<div class="controls" style="background: #e7f3ff; border-left: 4px solid #007bff;">
<div class="control-group">
<label class="file-button" for="imageFolder">📁 选择图片文件夹</label>
<input type="file" id="imageFolder" class="file-input" webkitdirectory directory multiple>
<span id="imageFolderStatus" style="color: #666;">未选择</span>
</div>
<div class="control-group">
<label class="file-button" for="labelFolder">📄 选择标签文件夹</label>
<input type="file" id="labelFolder" class="file-input" webkitdirectory directory multiple>
<span id="labelFolderStatus" style="color: #666;">未选择</span>
</div>
</div>
<div id="message" style="display: none;"></div>
<div class="controls">
<div class="navigation">
<button id="prevBtn" disabled>← 上一张</button>
<div class="control-group">
<label>当前:</label>
<span id="currentIndex">0</span>
<span>/</span>
<span id="totalImages">0</span>
</div>
<button id="nextBtn" disabled>下一张 →</button>
</div>
<div class="control-group">
<label>跳转到:</label>
<select id="imageSelect" disabled>
<option>请先选择文件夹</option>
</select>
</div>
</div>
<div class="viewer">
<div class="canvas-container">
<canvas id="canvas"></canvas>
</div>
<div class="info-panel">
<div class="info-section">
<h3>⚙️ 类别设置</h3>
<textarea id="classInput" rows="4" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; font-family: monospace; font-size: 12px; margin-bottom: 5px;">0:ship</textarea>
<button id="updateClassBtn" style="width: 100%; padding: 6px;">更新类别</button>
<div style="margin-top: 10px; padding: 8px; background: #fff3cd; border-radius: 4px; font-size: 12px; color: #856404;">
💡 格式:每行一个<br>
0:类别名1<br>
1:类别名2
</div>
</div>
<div class="info-section">
<h3>🎨 类别图例</h3>
<div id="classLegend"></div>
</div>
<div class="info-section">
<h3>📷 当前图片信息</h3>
<div id="imageInfo">
<div class="info-item">请先选择图片和标签文件夹</div>
</div>
</div>
<div class="info-section">
<h3>🎯 检测目标</h3>
<div id="objectList">
<div class="info-item">暂无数据</div>
</div>
</div>
</div>
</div>
</div>
<script>
// 预定义颜色列表
const colorPalette = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8', '#F7DC6F',
'#FF8C94', '#A8E6CF', '#FFD3B6', '#FFAAA5', '#FF8B94', '#C7CEEA',
'#B4F8C8', '#FBE7C6', '#A0E7E5', '#FFAEBC', '#B4A7D6', '#FFC8DD',
'#BDE0FE', '#A2D2FF', '#CDB4DB', '#FFC6FF', '#FFAFCC', '#BDE4A7'
];
let classes = {
0: { name: 'ship', color: '#FF6B6B' }
};
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let imageFiles = {};
let labelFiles = {};
let imageList = [];
let currentIndex = 0;
// 解析类别输入
function parseClassInput() {
const input = document.getElementById('classInput').value;
const lines = input.trim().split('\n');
const newClasses = {};
lines.forEach((line, index) => {
line = line.trim();
if (!line) return;
// 支持多种格式: "0:name" 或 "0 name" 或 "name"
let classId, className;
if (line.includes(':')) {
const parts = line.split(':');
classId = parseInt(parts[0].trim());
className = parts[1].trim();
} else if (line.match(/^\d+\s+/)) {
const parts = line.split(/\s+/);
classId = parseInt(parts[0]);
className = parts.slice(1).join(' ');
} else {
classId = index;
className = line;
}
if (!isNaN(classId) && className) {
newClasses[classId] = {
name: className,
color: colorPalette[classId % colorPalette.length]
};
}
});
if (Object.keys(newClasses).length > 0) {
classes = newClasses;
initClassLegend();
showMessage(`成功更新 ${Object.keys(classes).length} 个类别`, 'success');
// 如果已经加载了图片,重新绘制
if (imageList.length > 0) {
loadCurrentImage();
}
} else {
showMessage('类别格式错误,请检查输入格式');
}
}
// 初始化类别图例
function initClassLegend() {
const legendDiv = document.getElementById('classLegend');
legendDiv.innerHTML = Object.entries(classes).map(([id, cls]) => `
<div class="class-legend">
<div class="color-box" style="background: ${cls.color};"></div>
<span>${id}: ${cls.name}</span>
</div>
`).join('');
}
// 显示消息
function showMessage(msg, type = 'error') {
const msgDiv = document.getElementById('message');
msgDiv.textContent = msg;
msgDiv.className = type;
msgDiv.style.display = 'block';
if (type === 'success') {
setTimeout(() => msgDiv.style.display = 'none', 3000);
}
}
// 处理图片文件夹选择
document.getElementById('imageFolder').addEventListener('change', function(e) {
imageFiles = {};
const files = Array.from(e.target.files);
files.forEach(file => {
if (file.name.match(/\.(png|jpg|jpeg|bmp)$/i)) {
// 提取文件名(不含扩展名)作为key
const basename = file.name.replace(/\.(png|jpg|jpeg|bmp)$/i, '');
imageFiles[basename] = file;
}
});
const count = Object.keys(imageFiles).length;
document.getElementById('imageFolderStatus').textContent = `✅ ${count} 张图片`;
document.getElementById('imageFolderStatus').style.color = '#28a745';
document.getElementById('imageFolderStatus').style.fontWeight = 'bold';
updateImageList();
if (count > 0) {
showMessage(`✅ 成功加载 ${count} 张图片`, 'success');
}
});
// 处理标签文件夹选择
document.getElementById('labelFolder').addEventListener('change', function(e) {
labelFiles = {};
const files = Array.from(e.target.files);
files.forEach(file => {
if (file.name.endsWith('.txt')) {
// 提取文件名(不含扩展名)作为key
const basename = file.name.replace(/\.txt$/, '');
labelFiles[basename] = file;
}
});
const count = Object.keys(labelFiles).length;
document.getElementById('labelFolderStatus').textContent = `✅ ${count} 个标签`;
document.getElementById('labelFolderStatus').style.color = '#28a745';
document.getElementById('labelFolderStatus').style.fontWeight = 'bold';
updateImageList();
if (count > 0) {
showMessage(`✅ 成功加载 ${count} 个标签文件`, 'success');
}
});
// 更新图片列表
function updateImageList() {
// 获取所有图片的文件名(不含扩展名)
imageList = Object.keys(imageFiles).sort((a, b) => {
// 尝试按数字排序,如果不是数字则按字符串排序
const numA = parseInt(a.match(/\d+/)?.[0]);
const numB = parseInt(b.match(/\d+/)?.[0]);
if (!isNaN(numA) && !isNaN(numB)) {
return numA - numB;
}
return a.localeCompare(b);
});
if (imageList.length > 0) {
document.getElementById('totalImages').textContent = imageList.length;
const select = document.getElementById('imageSelect');
const imageFile = imageFiles[imageList[0]];
const ext = imageFile.name.split('.').pop();
select.innerHTML = imageList.map(basename => {
const file = imageFiles[basename];
const hasLabel = labelFiles[basename] ? '✅' : '⚠️';
return `<option value="${basename}">${hasLabel} ${file.name}</option>`;
}).join('');
select.disabled = false;
document.getElementById('prevBtn').disabled = false;
document.getElementById('nextBtn').disabled = false;
currentIndex = 0;
loadCurrentImage();
}
}
// 加载当前图片
async function loadCurrentImage() {
if (imageList.length === 0) return;
const basename = imageList[currentIndex];
const imageFile = imageFiles[basename];
const labelFile = labelFiles[basename];
document.getElementById('currentIndex').textContent = currentIndex + 1;
document.getElementById('imageSelect').value = basename;
if (!imageFile) {
showMessage('图片文件不存在');
return;
}
// 加载图片
const img = new Image();
const reader = new FileReader();
reader.onload = function(e) {
img.onload = async function() {
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
// 更新图片信息
const labelStatus = labelFile ? '✅ 有标签' : '⚠️ 无标签';
document.getElementById('imageInfo').innerHTML = `
<div class="info-item"><strong>文件名:</strong> ${imageFile.name}</div>
<div class="info-item"><strong>尺寸:</strong> ${img.width} × ${img.height}</div>
<div class="info-item"><strong>标签:</strong> ${labelStatus}</div>
`;
// 加载标签
if (labelFile) {
await loadLabel(labelFile, img, imageFile.name);
} else {
document.getElementById('objectList').innerHTML =
'<div class="info-item" style="color: #dc3545;">⚠️ 无对应标签文件</div>';
}
};
img.src = e.target.result;
};
reader.readAsDataURL(imageFile);
}
// 加载标签
async function loadLabel(labelFile, img, imageName) {
const reader = new FileReader();
reader.onload = function(e) {
const labelText = e.target.result;
const lines = labelText.trim().split('\n').filter(line => line.trim());
let objectListHTML = '';
let objectCount = 0;
lines.forEach((line, idx) => {
const parts = line.trim().split(/\s+/).map(Number);
if (parts.length >= 9) {
objectCount++;
const classId = parts[0];
const points = [];
for (let i = 1; i < 9; i += 2) {
points.push({
x: parts[i] * img.width,
y: parts[i + 1] * img.height
});
}
// 绘制旋转框
const color = classes[classId]?.color || '#FFFFFF';
ctx.strokeStyle = color;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i].x, points[i].y);
}
ctx.closePath();
ctx.stroke();
// 绘制类别标签背景
const className = classes[classId]?.name || `Class_${classId}`;
ctx.font = 'bold 14px Arial';
const textWidth = ctx.measureText(className).width;
ctx.fillStyle = color;
ctx.fillRect(points[0].x, points[0].y - 20, textWidth + 8, 18);
// 绘制类别文字
ctx.fillStyle = '#000';
ctx.fillText(className, points[0].x + 4, points[0].y - 6);
objectListHTML += `
<div class="info-item">
<strong style="color: ${color};">目标 ${idx + 1}:</strong> ${className}
</div>
`;
}
});
// 更新目标数量
const infoDiv = document.getElementById('imageInfo');
const labelStatus = objectCount > 0 ? '✅ 有标签' : '⚠️ 无标签';
infoDiv.innerHTML = `
<div class="info-item"><strong>文件名:</strong> ${imageName}</div>
<div class="info-item"><strong>尺寸:</strong> ${img.width} × ${img.height}</div>
<div class="info-item"><strong>标签:</strong> ${labelStatus}</div>
<div class="info-item"><strong>目标数量:</strong> ${objectCount}</div>
`;
document.getElementById('objectList').innerHTML =
objectListHTML || '<div class="info-item">✅ 标签文件存在,但无检测目标</div>';
};
reader.readAsText(labelFile);
}
// 导航按钮
document.getElementById('prevBtn').addEventListener('click', () => {
if (currentIndex > 0) {
currentIndex--;
loadCurrentImage();
}
});
document.getElementById('nextBtn').addEventListener('click', () => {
if (currentIndex < imageList.length - 1) {
currentIndex++;
loadCurrentImage();
}
});
document.getElementById('imageSelect').addEventListener('change', (e) => {
const selectedBasename = e.target.value;
currentIndex = imageList.indexOf(selectedBasename);
loadCurrentImage();
});
// 更新类别按钮
document.getElementById('updateClassBtn').addEventListener('click', parseClassInput);
// 键盘快捷键
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft' && currentIndex > 0) {
currentIndex--;
loadCurrentImage();
} else if (e.key === 'ArrowRight' && currentIndex < imageList.length - 1) {
currentIndex++;
loadCurrentImage();
}
});
// 初始化
initClassLegend();
</script>
</body>
</html>