旋转OBB数据集标注查看器

旋转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

🎯 适用场景

  • ✅ 检查数据集标注质量
  • ✅ 验证标注框位置是否正确
  • ✅ 查看类别分布情况
  • ✅ 发现标注错误或遗漏
  • ✅ 数据集预览和展示

💡 技巧

  1. 快速浏览:使用键盘左右箭头键比鼠标点击更快
  2. 自定义类别:可以随时修改类别名称,适配不同数据集
  3. 批量检查:按顺序浏览所有图片,确保标注一致性
  4. 颜色区分:不同类别自动分配不同颜色,便于识别

🔧 技术特点

  • 纯前端实现,无需后端服务器
  • 使用HTML5 Canvas绘制标注框
  • 支持本地文件系统访问(File API)
  • 响应式设计,适配不同屏幕尺寸

📋 系统要求

  • 现代浏览器(Chrome、Edge、Firefox等)
  • 支持HTML5和JavaScript
  • 无需安装任何依赖

🐛 常见问题

Q: 为什么看不到标注框?

A: 请确保:

  1. 图片和标签文件名对应(如 L532.png 对应 L532.txt)
  2. 标签文件格式正确(每行9个数字)
  3. 已经点击"更新类别"按钮

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>
相关推荐
玖日大大1 小时前
NLP—— 让机器读懂人类语言的艺术与科学
人工智能·自然语言处理
这张生成的图像能检测吗1 小时前
(论文速读)BV-DL:融合双目视觉和深度学习的高速列车轮轨动态位移检测
人工智能·深度学习·计算机视觉·关键点检测·双目视觉·激光传感器
nvd111 小时前
LLM 对话记忆功能实现深度解析
python
lxmyzzs1 小时前
在 RK3588 开发板上部署 DeepSeek-R1-Distill-Qwen-1.5B 模型:RKLLM API 实战指南
人工智能·rk3588·deepseek
老欧学视觉1 小时前
0011机器学习特征工程
人工智能·机器学习
科技观察1 小时前
国产MATLAB替代软件的关键能力与生态发展现状
大数据·人工智能·matlab
电饭叔1 小时前
Luhn算法初介绍
python
用户5191495848451 小时前
掌握比特币:开放区块链编程全解析
人工智能·aigc
badmonster01 小时前
实时代码库索引:用 CocoIndex 构建智能代码搜索的终极方案
python·rust