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

开发日记:YOLO 实例分割可视化工具
ytkz开发日记:YOLO 实例分割可视化工具(增强版)
日期:2026年5月14日
天气:暴雨,窗外有风
今天从早到晚一直在打磨一个 YOLO 实例分割可视化工具。原本只是一个简单的小脚本,用来显示分割标注的蒙版和边缘,但实际使用时发现不少痛点:拖拽图片和标签不同步、缩放查看不方便、实例信息不够直观……于是花了一整天做了“增强版”,趁现在思路还热着,赶紧记下来。
上午:确立需求与设计
接手前,旧版只有单次读取、固定显示,一旦图片超过屏幕就看不到全貌,更别说精细检查每一个实例的边界了。用户反馈最多的是:
- 大图查看困难 – 需要滚动或缩放。
- 标签格式支持弱 – 只支持绝对坐标,而YOLO分割常用的是归一化的多边形点。
- 交互不友好 – 图片和标注必须同时加载,不支持动态替换。
- 实例信息太少 – 看不出哪个实例对应哪个类别,也无法快速定位。
所以我决定重写前端,核心目标:
- 完全基于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 分割结果的快速可视化与检查需求了。代码干净,无外部依赖,部署即用。明天可以继续加上实例搜索和分类筛选功能。
写这篇日记时,窗外已经很安静了。有时候前端小工具带来的满足感,不亚于训练一个大模型。
明天计划:
- 实现实例点击定位并闪烁轮廓。
- 增加类别名称映射表,显示可读标签。
- 如果时间充裕,做一个“导出带标注的图片”按钮。
晚安。
下面是全部的代码
<!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>



