开发日记:YOLO 实例分割可视化工具

开发日记:YOLO 实例分割可视化工具(增强版)

日期:2026年5月14日
天气:暴雨,窗外有风

今天从早到晚一直在打磨一个 YOLO 实例分割可视化工具。原本只是一个简单的小脚本,用来显示分割标注的蒙版和边缘,但实际使用时发现不少痛点:拖拽图片和标签不同步、缩放查看不方便、实例信息不够直观……于是花了一整天做了“增强版”,趁现在思路还热着,赶紧记下来。


上午:确立需求与设计

接手前,旧版只有单次读取、固定显示,一旦图片超过屏幕就看不到全貌,更别说精细检查每一个实例的边界了。用户反馈最多的是:

  1. 大图查看困难 – 需要滚动或缩放。
  2. 标签格式支持弱 – 只支持绝对坐标,而YOLO分割常用的是归一化的多边形点。
  3. 交互不友好 – 图片和标注必须同时加载,不支持动态替换。
  4. 实例信息太少 – 看不出哪个实例对应哪个类别,也无法快速定位。

所以我决定重写前端,核心目标:

  • 完全基于Canvas + 原生JavaScript(轻量,无框架依赖)。
  • 支持拖拽加载图片和TXT标签文件(两者独立,先图后标签或反过来都能工作)。
  • 实时透明度调节 – 叠加蒙版时能看到背景原图,方便检查对齐。
  • 缩放与适配 – 提供“适应窗口”和“原始大小”两个模式,加上滚动条,支持任意分辨率。
  • 实例列表 – 右侧面板列出每个实例的序号、类别ID、轮廓点数,点击高亮(后续可加)。

中午:核心渲染逻辑的实现

Canvas 的方案一开始就定下来:保持画布实际像素 = 图片原始尺寸,这样标注中归一化的多边形点 (x,y) 可以直接乘以画布宽高得到像素坐标,不会失真。然后通过 CSS 的 width/height 属性来视觉缩放图片和蒙版,同时保持滚动条可用。

关键代码片段:

canvas.width = image.width;
canvas.height = image.height;
canvas.style.width = `${image.width * scale}px`;
canvas.style.height = `${image.height * scale}px`;

绘制时先画原图,再逐实例绘制半透明填充+彩色描边,最后在重心位置标注实例序号。

遇到的坑
ctx.globalAlpha 会影响后面的描边,所以填充前设 alpha,填充完立马恢复 1.0,否则描边也会变淡。
另外,适应窗口的算法不仅要考虑容器尺寸,还要限制最大缩放倍数为 2,避免小图被放得过于模糊(既然是标注检查,适度放大可以,但太大会失真)。


下午:拖拽交互与容错处理

拖拽区分为“图片区”和“标签区”两个盒子,各自监听 dragover/dragleave/drop
图片拖拽后自动重置当前标签(因为图片变了,之前的标注无意义);标签拖拽后若还没有图片,则提示“请先拖入图片”,防止空指针。

标签解析完全按照 YOLO 分割格式:每行第一个数字是 class_id,之后每两个数字为一组归一化坐标 x y,至少需要三个点才能构成闭合多边形。为了鲁棒性,我过滤掉点数不足的实例,并允许行末有空格或换行。

右侧面板动态生成每个实例的简要信息,颜色根据实例索引从预定义色板中选取,保持视觉区分度。未来可以考虑加入“鼠标悬浮高亮对应实例”的功能,今天时间有限,留作下次迭代。


傍晚:微调与测试

测试了几组不同大小的图片:

  • 小图 (640×640):适应窗口后显示合适,放大2倍看得清每个点。
  • 超大图 (4000×3000):原始大小时画布实际宽高很大,但 CSS 缩放后仍能流畅绘制,滚动查看细节没问题。
  • 多实例(十多个):右侧列表没有乱序,蒙版颜色鲜艳且不重叠,实例编号定位在重心,不会跑偏。

透明度滑块从 0 到 100,滑到 30% 左右最合适,既能看到原图纹理,又能清楚覆盖区域。

一个意外:Chrome 对超大 Canvas 的内存限制并不苛刻,但绘制大量多边形时性能略有下降。因此我在 draw() 中每次重绘都执行 clearRect + 全图重绘 + 所有多边形绘制,对于 4000 分辨率 + 20 个实例依然能保持 60fps 左右,满足了。


晚上:收尾与反思

给顶部栏增加了“适应窗口”和“原始大小”按钮,监听窗口 resize 事件自动调用适应窗口(避免溢出)。
此外,将透明度滑块的实时事件绑定到 draw,用户操作时画面立刻反馈,体验流畅。

待改进的点

  • 目前实例列表中点击某实例并没有高亮对应蒙版,需要增加 “选中实例” 功能。
  • 支持保存标注后的图像截图(带蒙版的 PNG)。
  • 支持更多标签格式(COCO、自定义 JSON)。

不过总的来说,今天这个增强版已经能完全满足日常 YOLO 分割结果的快速可视化与检查需求了。代码干净,无外部依赖,部署即用。明天可以继续加上实例搜索和分类筛选功能。

写这篇日记时,窗外已经很安静了。有时候前端小工具带来的满足感,不亚于训练一个大模型。


明天计划

  1. 实现实例点击定位并闪烁轮廓。
  2. 增加类别名称映射表,显示可读标签。
  3. 如果时间充裕,做一个“导出带标注的图片”按钮。

晚安。

下面是全部的代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>YOLO 实例分割可视化工具(增强版)</title>
<style>
    * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
    }
    body {
        font-family: Arial, Helvetica, sans-serif;
        background: #111;
        color: white;
        overflow: hidden;
        display: flex;
        flex-direction: column;
        height: 100vh;
    }
    .top-bar {
        height: 70px;
        background: #1b1b1b;
        display: flex;
        align-items: center;
        gap: 20px;
        padding: 10px 20px;
        border-bottom: 1px solid #333;
        flex-shrink: 0;
    }
    .drop-box {
        width: 220px;
        height: 50px;
        border: 2px dashed #666;
        border-radius: 10px;
        display: flex;
        align-items: center;
        justify-content: center;
        cursor: pointer;
        transition: 0.2s;
        font-size: 14px;
    }
    .drop-box.dragover {
        border-color: #00ff99;
        background: #163326;
    }
    .btn {
        background: #2c2c2c;
        border: none;
        color: white;
        padding: 8px 16px;
        border-radius: 6px;
        cursor: pointer;
        font-size: 14px;
    }
    .btn:hover {
        background: #3a3a3a;
    }
    .info {
        color: #aaa;
        font-size: 14px;
        margin-left: auto;
    }
    .main {
        flex: 1;
        background: #000;
        position: relative;
        overflow: auto;          /* 关键:出现滚动条 */
        display: flex;
        justify-content: flex-start;
        align-items: flex-start;
    }
    canvas {
        display: block;
        border: 1px solid #333;
        /* 取消 max-width / max-height,保留原始尺寸,通过滚动查看 */
    }
    .right-panel {
        position: fixed;
        right: 10px;
        top: 80px;
        width: 260px;
        background: rgba(0,0,0,0.85);
        border: 1px solid #333;
        border-radius: 10px;
        padding: 10px;
        overflow-y: auto;
        max-height: 80%;
        backdrop-filter: blur(5px);
        z-index: 10;
    }
    .right-panel h3 {
        margin-bottom: 10px;
        color: #00ff99;
    }
    .item {
        margin-bottom: 8px;
        padding: 6px;
        background: #1d1d1d;
        border-radius: 6px;
        font-size: 13px;
    }
    .slider-group {
        display: flex;
        align-items: center;
        gap: 10px;
    }
    input[type=range] {
        width: 150px;
    }
    .zoom-controls {
        display: flex;
        gap: 10px;
        margin-left: 10px;
    }
</style>
</head>
<body>

<div class="top-bar">
    <div class="drop-box" id="imageDrop">拖拽图片 (PNG/JPG)</div>
    <div class="drop-box" id="labelDrop">拖拽 TXT 标签</div>
    <div class="slider-group">
        透明度
        <input type="range" min="0" max="100" value="40" id="alphaSlider">
    </div>
    <div class="zoom-controls">
        <button class="btn" id="zoomFit">适应窗口</button>
        <button class="btn" id="zoomReset">原始大小</button>
    </div>
    <div class="info" id="info">等待加载...</div>
</div>

<div class="main" id="mainContainer">
    <canvas id="canvas"></canvas>
</div>

<div class="right-panel">
    <h3>实例信息</h3>
    <div id="instanceList"></div>
</div>

<script>
    const canvas = document.getElementById("canvas");
    const ctx = canvas.getContext("2d");
    const mainContainer = document.getElementById("mainContainer");

    const imageDrop = document.getElementById("imageDrop");
    const labelDrop = document.getElementById("labelDrop");
    const info = document.getElementById("info");
    const instanceList = document.getElementById("instanceList");
    const alphaSlider = document.getElementById("alphaSlider");
    const zoomFitBtn = document.getElementById("zoomFit");
    const zoomResetBtn = document.getElementById("zoomReset");

    let image = null;
    let labels = [];          // 当前显示的标注
    let currentScale = 1;    // 额外缩放因子(适应窗口时使用)
    let originalCanvasWidth = 0, originalCanvasHeight = 0;

    // 随机颜色
    function randomColor(id) {
        const colors = [
            "#ff4d4d","#4dff88","#4da6ff","#ffcc00","#cc66ff",
            "#00e6e6","#ff884d","#66ff66","#ff66b3","#66ccff"
        ];
        return colors[id % colors.length];
    }

    // 更新右侧面板
    function updateInstancePanel() {
        instanceList.innerHTML = "";
        if (!labels.length) {
            instanceList.innerHTML = '<div class="item">无标注数据</div>';
            return;
        }
        labels.forEach((obj, idx) => {
            const div = document.createElement("div");
            div.className = "item";
            div.innerHTML = `<b>实例 ${idx+1}</b><br>类别: ${obj.classId}<br>点数: ${obj.points.length}`;
            instanceList.appendChild(div);
        });
    }

    // 解析标签文件 (YOLO seg 格式)
    function parseLabels(text) {
        const newLabels = [];
        const lines = text.trim().split("\n");
        for (let line of lines) {
            const arr = line.trim().split(/\s+/).map(Number);
            if (arr.length < 7) continue;
            const cls = arr[0];
            let points = [];
            for (let i = 1; i < arr.length; i += 2) {
                points.push({ x: arr[i], y: arr[i+1] });
            }
            if (points.length >= 3) {
                newLabels.push({ classId: cls, points: points });
            }
        }
        labels = newLabels;
        updateInstancePanel();
        draw();  // 重新绘制
    }

    // 绘制核心:使用原始尺寸画布,然后根据缩放因子进行显示适配
    // 注意:canvas.width/height 始终保持图像原始尺寸(绘制坐标系不变)
    // 但 canvas 的 CSS 宽高会被缩放,绘制内容会自动缩放(因为 canvas 默认是位图缩放)。
    // 更严谨的做法:保持 canvas 实际像素 = 图像原始尺寸,然后通过 CSS transform 或者设置 canvas style 缩放。
    // 为了简单且保证坐标对应,我们采用设置 canvas 的 width/height 为图像实际尺寸,再通过 CSS 的 width/height 进行视觉缩放。
    // 这样标注的点坐标(归一化后乘以原始宽高)依然正确,且图像拉伸后标注也跟着拉伸,完全正确。
    function draw() {
        if (!image) return;

        // 确保 canvas 实际像素尺寸 = 原始图片尺寸
        canvas.width = image.width;
        canvas.height = image.height;
        originalCanvasWidth = image.width;
        originalCanvasHeight = image.height;

        // 应用缩放因子到 CSS 显示尺寸
        const displayWidth = image.width * currentScale;
        const displayHeight = image.height * currentScale;
        canvas.style.width = `${displayWidth}px`;
        canvas.style.height = `${displayHeight}px`;

        // 绘制图像
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        ctx.drawImage(image, 0, 0, canvas.width, canvas.height);

        const alpha = alphaSlider.value / 100;

        // 绘制每个实例
        labels.forEach((obj, idx) => {
            const color = randomColor(idx);
            ctx.beginPath();
            const points = obj.points;
            if (points.length === 0) return;

            for (let i = 0; i < points.length; i++) {
                const x = points[i].x * canvas.width;
                const y = points[i].y * canvas.height;
                if (i === 0) ctx.moveTo(x, y);
                else ctx.lineTo(x, y);
            }
            ctx.closePath();

            ctx.globalAlpha = alpha;
            ctx.fillStyle = color;
            ctx.fill();
            ctx.globalAlpha = 1.0;
            ctx.strokeStyle = color;
            ctx.lineWidth = 2;
            ctx.stroke();

            // 显示编号(中心点)
            let cx = 0, cy = 0;
            for (let p of points) {
                cx += p.x * canvas.width;
                cy += p.y * canvas.height;
            }
            cx /= points.length;
            cy /= points.length;
            ctx.fillStyle = "#ffffff";
            ctx.font = `${Math.max(14, Math.floor(20 * currentScale))}px Arial`;
            ctx.shadowBlur = 0;
            ctx.fillText(`${idx+1}`, cx, cy);
        });

        info.innerHTML = `图片: ${image.width}×${image.height} | 实例: ${labels.length} | 显示缩放: ${currentScale.toFixed(2)}x`;
    }

    // 适应窗口:根据容器大小计算合适的缩放比例,留出一些边距
    function fitToWindow() {
        if (!image) return;
        const containerRect = mainContainer.getBoundingClientRect();
        const margin = 20;
        const maxWidth = containerRect.width - margin;
        const maxHeight = containerRect.height - margin;
        const scaleX = maxWidth / image.width;
        const scaleY = maxHeight / image.height;
        const newScale = Math.min(scaleX, scaleY, 2); // 最大不超过2倍
        currentScale = Math.max(0.1, newScale);
        draw();
        // 滚动到左上角
        mainContainer.scrollTop = 0;
        mainContainer.scrollLeft = 0;
    }

    function resetZoom() {
        currentScale = 1;
        draw();
    }

    // 加载图片
    function loadImage(file) {
        const reader = new FileReader();
        reader.onload = function(e) {
            image = new Image();
            image.onload = function() {
                // 重置标签(因为新图片,旧标签无效)
                labels = [];
                updateInstancePanel();
                currentScale = 1;
                draw();
                fitToWindow(); // 自动适应窗口,避免显示不全
            };
            image.src = e.target.result;
        };
        reader.readAsDataURL(file);
    }

    function loadLabel(file) {
        const reader = new FileReader();
        reader.onload = function(e) {
            if (!image) {
                alert("请先拖入图片,再拖入标签文件");
                return;
            }
            parseLabels(e.target.result);
        };
        reader.readAsText(file);
    }

    // 拖拽交互设置
    function setupDrop(element, callback) {
        element.addEventListener("dragover", (e) => {
            e.preventDefault();
            element.classList.add("dragover");
        });
        element.addEventListener("dragleave", () => {
            element.classList.remove("dragover");
        });
        element.addEventListener("drop", (e) => {
            e.preventDefault();
            element.classList.remove("dragover");
            const file = e.dataTransfer.files[0];
            if (file) callback(file);
        });
    }

    setupDrop(imageDrop, loadImage);
    setupDrop(labelDrop, loadLabel);

    alphaSlider.addEventListener("input", () => draw());
    zoomFitBtn.addEventListener("click", fitToWindow);
    zoomResetBtn.addEventListener("click", resetZoom);

    // 当窗口大小改变时,重新适应窗口(如果已经适应过)
    window.addEventListener("resize", () => {
        if (image && currentScale !== 1) {
            // 重新计算适应,但不改变用户主动的缩放?简单起见:如果当前处于非1倍模式,自动适应一次?
            // 或者不自动适应,保持原样。但推荐适应,让视图不溢出。
            fitToWindow();
        } else if (image) {
            fitToWindow();
        }
    });
</script>
</body>
</html>

image-20260514112712233