Spaces:
Running
Running
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>Advanced YOLO Segmentation Viewer</title> | |
<script src="https://cdn.tailwindcss.com"></script> | |
<script src="https://unpkg.com/@phosphor-icons/web"></script> | |
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> | |
<style> | |
:root { | |
--bg-dark: #0f172a; /* slate-900 */ | |
--bg-panel: #1e293b; /* slate-800 */ | |
--bg-interactive: #334155; /* slate-700 */ | |
--border-color: #475569; /* slate-600 */ | |
--text-primary: #f8fafc; /* slate-50 */ | |
--text-secondary: #cbd5e1; /* slate-300 */ | |
--accent-color: #2563eb; /* blue-600 */ | |
} | |
body { | |
font-family: 'Inter', sans-serif; | |
background-color: var(--bg-dark); | |
color: var(--text-secondary); | |
overscroll-behavior: none; | |
} | |
.control-panel { | |
background-color: var(--bg-panel); | |
} | |
.tab-button { | |
transition: background-color 0.2s, color 0.2s, box-shadow 0.2s; | |
} | |
.tab-button.active { | |
background-color: var(--accent-color); | |
color: var(--text-primary); | |
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.1), 0 1px 2px 0 rgba(0,0,0,0.06); | |
} | |
.file-input-label { | |
cursor: pointer; | |
border: 2px dashed var(--border-color); | |
padding: 1.5rem 1rem; | |
text-align: center; | |
transition: background-color 0.2s, border-color 0.2s; | |
} | |
.file-input-label.drag-over { | |
background-color: var(--bg-interactive); | |
border-color: var(--accent-color); | |
} | |
main { | |
background-image: linear-gradient(var(--border-color) 1px, transparent 1px), linear-gradient(to right, var(--border-color) 1px, var(--bg-dark) 1px); | |
background-size: 20px 20px; | |
} | |
canvas { | |
max-width: 100%; | |
max-height: calc(100vh - 3rem); | |
border-radius: 0.75rem; | |
background-color: #000; | |
object-fit: contain; | |
box-shadow: 0 10px 15px -3px rgba(0,0,0,0.2), 0 4px 6px -2px rgba(0,0,0,0.1); | |
border: 1px solid var(--border-color); | |
} | |
.btn { | |
transition: background-color 0.2s, transform 0.1s; | |
} | |
.btn:active { | |
transform: scale(0.97); | |
} | |
#toast { | |
position: fixed; bottom: 20px; left: 50%; | |
transform: translateX(-50%); | |
padding: 12px 24px; border-radius: 8px; color: white; | |
opacity: 0; visibility: hidden; | |
transition: all 0.3s ease; | |
z-index: 1000; | |
} | |
#toast.show { opacity: 1; visibility: visible; transform: translateX(-50%) translateY(0); } | |
#toast.error { background-color: #e11d48; } | |
#toast.success { background-color: #16a34a; } | |
.loader { | |
border: 3px solid #475569; | |
border-top: 3px solid var(--accent-color); | |
border-radius: 50%; | |
width: 20px; height: 20px; | |
animation: spin 1s linear infinite; | |
} | |
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } | |
.accordion-header { | |
transition: background-color 0.2s ease-in-out; | |
} | |
.accordion-header:hover { | |
background-color: var(--bg-interactive); | |
} | |
.accordion-content { | |
max-height: 0; | |
overflow: hidden; | |
transition: max-height 0.35s cubic-bezier(0.4, 0, 0.2, 1); | |
} | |
</style> | |
</head> | |
<body> | |
<div class="min-h-screen flex flex-col lg:flex-row"> | |
<!-- Control Panel --> | |
<aside class="w-full lg:w-96 p-6 control-panel flex-shrink-0 flex flex-col"> | |
<div class="flex-grow"> | |
<div class="flex justify-between items-center mb-6"> | |
<h1 class="text-2xl font-bold text-white">Segmentation Tool</h1> | |
<button id="resetBtn" title="Reset" class="p-2 rounded-full hover:bg-gray-700 btn"> | |
<i class="ph-bold ph-arrow-counter-clockwise text-xl"></i> | |
</button> | |
</div> | |
<!-- Accordion Sections --> | |
<div class="space-y-3" id="accordion-container"> | |
<!-- Image Source Accordion --> | |
<div class="bg-slate-900 rounded-lg"> | |
<button class="accordion-toggle w-full flex justify-between items-center p-4 text-left font-semibold text-white accordion-header rounded-lg" aria-expanded="false" aria-controls="accordion-content-1"> | |
<span>Image Source</span> | |
<i class="ph-bold ph-caret-down text-xl transform transition-transform"></i> | |
</button> | |
<div class="accordion-content" id="accordion-content-1"> | |
<div class="p-4 pt-2"> | |
<div class="tab-container bg-slate-800 rounded-lg p-1 mb-4 grid grid-cols-2 gap-1"> | |
<button data-tab-target="#img-content-upload" class="tab-button w-full py-2 rounded-md text-sm font-medium flex items-center justify-center gap-2"> | |
<i class="ph-bold ph-upload-simple"></i> Upload | |
</button> | |
<button data-tab-target="#img-content-url" class="tab-button w-full py-2 rounded-md text-sm font-medium flex items-center justify-center gap-2"> | |
<i class="ph-bold ph-link"></i> URL | |
</button> | |
</div> | |
<div id="img-content-upload" class="tab-content"> | |
<label for="imageFileInput" id="imageDropZone" class="file-input-label rounded-lg block"> | |
<i class="ph-bold ph-image text-3xl text-slate-500 mb-2"></i> | |
<span class="text-blue-400 font-semibold text-sm">Click to upload</span> | |
<p id="imageFileName" class="text-xs text-gray-400 mt-1 truncate">or drag and drop</p> | |
</label> | |
<input type="file" id="imageFileInput" accept="image/*" class="hidden"> | |
</div> | |
<div id="img-content-url" class="tab-content hidden"> | |
<div class="relative"> | |
<input type="text" id="imageUrlInput" class="w-full text-sm bg-slate-700 border border-slate-600 rounded-md py-2 px-3 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="https://..."> | |
<div id="urlLoader" class="loader absolute right-2 top-1/2 -translate-y-1/2 hidden"></div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
<!-- Segmentation Data Accordion --> | |
<div class="bg-slate-900 rounded-lg"> | |
<button class="accordion-toggle w-full flex justify-between items-center p-4 text-left font-semibold text-white accordion-header rounded-lg" aria-expanded="false" aria-controls="accordion-content-2"> | |
<span>Segmentation Data</span> | |
<i class="ph-bold ph-caret-down text-xl transform transition-transform"></i> | |
</button> | |
<div class="accordion-content" id="accordion-content-2"> | |
<div class="p-4 pt-2"> | |
<div class="tab-container bg-slate-800 rounded-lg p-1 mb-4 grid grid-cols-2 gap-1"> | |
<button data-tab-target="#seg-content-upload" class="tab-button w-full py-2 rounded-md text-sm font-medium flex items-center justify-center gap-2"> | |
<i class="ph-bold ph-file-text"></i> Upload .txt | |
</button> | |
<button data-tab-target="#seg-content-paste" class="tab-button w-full py-2 rounded-md text-sm font-medium flex items-center justify-center gap-2"> | |
<i class="ph-bold ph-clipboard-text"></i> Paste | |
</button> | |
</div> | |
<div id="seg-content-upload" class="tab-content"> | |
<label for="segmentFileInput" id="segmentDropZone" class="file-input-label rounded-lg block"> | |
<i class="ph-bold ph-file-arrow-up text-3xl text-slate-500 mb-2"></i> | |
<span class="text-blue-400 font-semibold text-sm">Upload a .txt file</span> | |
<p id="segmentFileName" class="text-xs text-gray-400 mt-1 truncate">or drag and drop</p> | |
</label> | |
<input type="file" id="segmentFileInput" accept=".txt,text/plain" class="hidden"> | |
</div> | |
<div id="seg-content-paste" class="tab-content hidden"> | |
<textarea id="segmentTextInput" rows="5" class="w-full text-sm bg-slate-700 border border-slate-600 rounded-md py-2 px-3 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="5 0.2869..."></textarea> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</aside> | |
<!-- Canvas Area --> | |
<main class="w-full flex-grow p-6 flex items-center justify-center"> | |
<canvas id="canvas"></canvas> | |
</main> | |
<!-- Toast Notification --> | |
<div id="toast"></div> | |
</div> | |
<script> | |
window.onload = () => { | |
// --- DOM Elements --- | |
const canvas = document.getElementById('canvas'); | |
const ctx = canvas.getContext('2d'); | |
const toast = document.getElementById('toast'); | |
const urlLoader = document.getElementById('urlLoader'); | |
const imageFileInput = document.getElementById('imageFileInput'); | |
const imageUrlInput = document.getElementById('imageUrlInput'); | |
const imageDropZone = document.getElementById('imageDropZone'); | |
const imageFileName = document.getElementById('imageFileName'); | |
const segmentFileInput = document.getElementById('segmentFileInput'); | |
const segmentTextInput = document.getElementById('segmentTextInput'); | |
const segmentDropZone = document.getElementById('segmentDropZone'); | |
const segmentFileName = document.getElementById('segmentFileName'); | |
const resetBtn = document.getElementById('resetBtn'); | |
const accordionContainer = document.getElementById('accordion-container'); | |
const tabContainers = document.querySelectorAll('.tab-container'); | |
// --- App State --- | |
let currentImage = null; | |
let currentSegments = ""; | |
let debounceTimer; | |
const colorCache = {}; | |
// --- Initial Setup --- | |
const initialSegments = "5 0.286970 0.727177 0.287063 0.727744 0.289340 0.735851 0.289584 0.736415 0.289851 0.736625 0.301154 0.735044 0.532361 0.658127 0.532491 0.658083 0.532982 0.657740 0.533126 0.657602 0.552022 0.638023 0.580098 0.608525 0.598898 0.588505 0.599084 0.588297 0.599637 0.587547 0.599758 0.587331 0.701179 0.331741 0.701206 0.331314 0.700942 0.330894 0.696238 0.326761 0.695860 0.326574 0.677053 0.324312 0.402586 0.299712 0.400998 0.299601 0.397723 0.300099 0.395015 0.302135 0.393809 0.303167 0.391261 0.305626 0.389437 0.309499 0.388740 0.311574 0.287006 0.702914"; | |
segmentTextInput.value = initialSegments; | |
currentSegments = initialSegments; | |
drawPlaceholder('Select an image to begin.'); | |
// --- Functions --- | |
/** | |
* Generates a consistent, vibrant color for a given class ID. | |
* Caches the color to ensure the same class ID always gets the same color. | |
* @param {number} classId - The class ID of the object. | |
* @returns {string} An HSL color string. | |
*/ | |
function getColorForClassId(classId) { | |
if (colorCache[classId]) { | |
return colorCache[classId]; | |
} | |
// Use a golden angle to generate distinct hues | |
const hue = (classId * 137.508) % 360; | |
const color = `hsl(${hue}, 85%, 60%)`; | |
colorCache[classId] = color; | |
return color; | |
} | |
function showToast(message, type = 'error') { | |
toast.textContent = message; | |
toast.className = type; | |
toast.classList.add('show'); | |
setTimeout(() => toast.classList.remove('show'), 3000); | |
} | |
function drawPlaceholder(message) { | |
const dpr = window.devicePixelRatio || 1; | |
canvas.width = 500 * dpr; | |
canvas.height = 300 * dpr; | |
canvas.style.width = '500px'; | |
canvas.style.height = '300px'; | |
ctx.scale(dpr, dpr); | |
ctx.fillStyle = '#000'; | |
ctx.fillRect(0, 0, 500, 300); | |
ctx.fillStyle = '#334155'; | |
ctx.font = '80px Inter'; | |
ctx.textAlign = 'center'; | |
ctx.textBaseline = 'middle'; | |
ctx.fillText('🖼️', 250, 130); | |
ctx.fillStyle = '#cbd5e1'; | |
ctx.font = '14px Inter'; | |
ctx.fillText(message, 250, 220); | |
} | |
function render() { | |
if (!currentImage) { | |
drawPlaceholder('Please select an image first.'); | |
return; | |
} | |
const dpr = window.devicePixelRatio || 1; | |
canvas.width = currentImage.width * dpr; | |
canvas.height = currentImage.height * dpr; | |
canvas.style.width = `${currentImage.width}px`; | |
canvas.style.height = `${currentImage.height}px`; | |
ctx.scale(dpr, dpr); | |
ctx.drawImage(currentImage, 0, 0); | |
if (!currentSegments) return; | |
const segmentLines = currentSegments.split('\n').filter(line => line.trim() !== ''); | |
segmentLines.forEach(drawSingleSegment); | |
} | |
function drawSingleSegment(line) { | |
const parts = line.trim().split(' ').map(Number); | |
const classId = parts[0]; | |
const coords = parts.slice(1); | |
if (coords.length < 6 || coords.length % 2 !== 0) return; | |
const color = getColorForClassId(classId); | |
ctx.beginPath(); | |
ctx.moveTo(coords[0] * currentImage.width, coords[1] * currentImage.height); | |
for (let i = 2; i < coords.length; i += 2) { | |
ctx.lineTo(coords[i] * currentImage.width, coords[i + 1] * currentImage.height); | |
} | |
ctx.closePath(); | |
// Set fill style with some transparency | |
ctx.fillStyle = color.replace(')', ', 0.4)').replace('hsl', 'hsla'); | |
ctx.fill(); | |
// Set stroke style with full opacity | |
ctx.strokeStyle = color; | |
ctx.lineWidth = Math.max(2, currentImage.width / 400); | |
ctx.stroke(); | |
let centroidX = 0, centroidY = 0; | |
for (let i = 0; i < coords.length; i += 2) { | |
centroidX += coords[i]; | |
centroidY += coords[i+1]; | |
} | |
centroidX = (centroidX / (coords.length / 2)) * currentImage.width; | |
centroidY = (centroidY / (coords.length / 2)) * currentImage.height; | |
const fontSize = Math.max(16, currentImage.width / 50); | |
ctx.font = `bold ${fontSize}px Inter`; | |
ctx.fillStyle = 'white'; | |
ctx.textAlign = 'center'; | |
ctx.textBaseline = 'middle'; | |
ctx.shadowColor = 'black'; | |
ctx.shadowBlur = 5; | |
ctx.fillText(classId, centroidX, centroidY); | |
ctx.shadowBlur = 0; | |
} | |
function loadImage(src) { | |
urlLoader.classList.remove('hidden'); | |
const img = new Image(); | |
img.crossOrigin = "anonymous"; | |
img.onload = () => { | |
currentImage = img; | |
render(); | |
urlLoader.classList.add('hidden'); | |
}; | |
img.onerror = () => { | |
showToast('Error loading image.', 'error'); | |
drawPlaceholder('Error loading image.'); | |
urlLoader.classList.add('hidden'); | |
}; | |
img.src = src; | |
} | |
function resetAll() { | |
currentImage = null; | |
currentSegments = initialSegments; | |
imageFileInput.value = ''; | |
segmentFileInput.value = ''; | |
imageUrlInput.value = ''; | |
segmentTextInput.value = initialSegments; | |
imageFileName.textContent = 'or drag and drop'; | |
segmentFileName.textContent = 'or drag and drop'; | |
drawPlaceholder('Select an image to begin.'); | |
} | |
// --- Event Listeners --- | |
function setupDragDrop(zone, fileInput, onFileLoad) { | |
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eName => zone.addEventListener(eName, e => { e.preventDefault(); e.stopPropagation(); })); | |
['dragenter', 'dragover'].forEach(eName => zone.addEventListener(eName, () => zone.classList.add('drag-over'))); | |
['dragleave', 'drop'].forEach(eName => zone.addEventListener(eName, () => zone.classList.remove('drag-over'))); | |
zone.addEventListener('drop', e => { | |
fileInput.files = e.dataTransfer.files; | |
onFileLoad(fileInput.files[0]); | |
}); | |
} | |
function handleImageFile(file) { | |
if (file) { | |
imageFileName.textContent = file.name; | |
loadImage(URL.createObjectURL(file)); | |
} | |
} | |
imageFileInput.addEventListener('change', (e) => handleImageFile(e.target.files[0])); | |
setupDragDrop(imageDropZone, imageFileInput, handleImageFile); | |
imageUrlInput.addEventListener('input', () => { | |
clearTimeout(debounceTimer); | |
debounceTimer = setTimeout(() => { | |
const url = imageUrlInput.value.trim(); | |
if (url) loadImage(url); | |
}, 500); | |
}); | |
function handleSegmentFile(file) { | |
if(file) { | |
segmentFileName.textContent = file.name; | |
const reader = new FileReader(); | |
reader.onload = (ev) => { | |
currentSegments = ev.target.result; | |
segmentTextInput.value = currentSegments; | |
render(); | |
}; | |
reader.readAsText(file); | |
} | |
} | |
segmentFileInput.addEventListener('change', (e) => handleSegmentFile(e.target.files[0])); | |
setupDragDrop(segmentDropZone, segmentFileInput, handleSegmentFile); | |
segmentTextInput.addEventListener('input', () => { | |
clearTimeout(debounceTimer); | |
debounceTimer = setTimeout(() => { | |
currentSegments = segmentTextInput.value; | |
render(); | |
}, 300); | |
}); | |
resetBtn.addEventListener('click', resetAll); | |
// Accordion Logic | |
accordionContainer.addEventListener('click', (e) => { | |
const toggle = e.target.closest('.accordion-toggle'); | |
if (!toggle) return; | |
const content = toggle.nextElementSibling; | |
const isExpanded = toggle.getAttribute('aria-expanded') === 'true'; | |
// Close all accordions | |
accordionContainer.querySelectorAll('.accordion-toggle').forEach(t => { | |
if (t !== toggle) { | |
t.setAttribute('aria-expanded', 'false'); | |
t.querySelector('i.ph-caret-down').classList.remove('-rotate-180'); | |
t.nextElementSibling.style.maxHeight = null; | |
} | |
}); | |
// Toggle the clicked one | |
if (!isExpanded) { | |
toggle.setAttribute('aria-expanded', 'true'); | |
toggle.querySelector('i.ph-caret-down').classList.add('-rotate-180'); | |
content.style.maxHeight = content.scrollHeight + "px"; | |
} else { | |
toggle.setAttribute('aria-expanded', 'false'); | |
toggle.querySelector('i.ph-caret-down').classList.remove('-rotate-180'); | |
content.style.maxHeight = null; | |
} | |
}); | |
// Tab Logic | |
tabContainers.forEach(container => { | |
const tabs = container.querySelectorAll('.tab-button'); | |
// Set initial active tab | |
tabs[0].classList.add('active'); | |
const initialContent = document.querySelector(tabs[0].dataset.tabTarget); | |
if(initialContent) initialContent.classList.remove('hidden'); | |
container.addEventListener('click', (e) => { | |
const clickedTab = e.target.closest('.tab-button'); | |
if (!clickedTab) return; | |
// Deactivate all tabs and hide all content panels in this group | |
const parentContent = clickedTab.closest('.accordion-content'); | |
tabs.forEach(tab => tab.classList.remove('active')); | |
parentContent.querySelectorAll('.tab-content').forEach(content => content.classList.add('hidden')); | |
// Activate the clicked tab and show its content | |
clickedTab.classList.add('active'); | |
const targetContent = document.querySelector(clickedTab.dataset.tabTarget); | |
if (targetContent) { | |
targetContent.classList.remove('hidden'); | |
} | |
}); | |
}); | |
// Open the first accordion by default | |
const firstAccordion = accordionContainer.querySelector('.accordion-toggle'); | |
if (firstAccordion) { | |
firstAccordion.click(); | |
} | |
}; | |
</script> | |
</body> | |
</html> | |