Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>AnnotateIt - Image Markup Tool</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://unpkg.com/feather-icons"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script> | |
| <style> | |
| #canvas-container { | |
| position: relative; | |
| display: inline-block; | |
| box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); | |
| } | |
| #image-canvas, #drawing-canvas { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| } | |
| .annotation-item:hover { | |
| background-color: rgba(59, 130, 246, 0.1); | |
| } | |
| .coordinates-display { | |
| font-family: monospace; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-50 min-h-screen"> | |
| <div class="container mx-auto px-4 py-8"> | |
| <header class="mb-8 text-center"> | |
| <h1 class="text-4xl font-bold text-indigo-600 mb-2">AnnotateIt 🎨</h1> | |
| <p class="text-lg text-gray-600">Precision image annotation tool with coordinate export</p> | |
| </header> | |
| <div class="grid grid-cols-1 lg:grid-cols-3 gap-8"> | |
| <div class="lg:col-span-2"> | |
| <div class="bg-white rounded-xl shadow-md overflow-hidden mb-6"> | |
| <div class="p-4 bg-indigo-50 border-b border-indigo-100"> | |
| <h2 class="text-xl font-semibold text-indigo-700">Image Annotation Canvas</h2> | |
| </div> | |
| <div class="p-4"> | |
| <div id="canvas-container" class="mx-auto"> | |
| <canvas id="image-canvas"></canvas> | |
| <canvas id="drawing-canvas"></canvas> | |
| </div> | |
| </div> | |
| <div class="p-4 bg-gray-50 border-t border-gray-200 flex flex-wrap gap-4"> | |
| <button id="upload-btn" class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition flex items-center gap-2"> | |
| <i data-feather="upload"></i> Upload Image | |
| </button> | |
| <input type="file" id="file-input" accept="image/*" class="hidden"> | |
| <button id="clear-btn" class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition flex items-center gap-2"> | |
| <i data-feather="trash-2"></i> Clear Annotations | |
| </button> | |
| <button id="export-btn" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition flex items-center gap-2 ml-auto"> | |
| <i data-feather="download"></i> Export Coordinates | |
| </button> | |
| </div> | |
| </div> | |
| <div class="bg-white rounded-xl shadow-md overflow-hidden"> | |
| <div class="p-4 bg-indigo-50 border-b border-indigo-100"> | |
| <h2 class="text-xl font-semibold text-indigo-700">Instructions</h2> | |
| </div> | |
| <div class="p-4"> | |
| <ol class="list-decimal pl-5 space-y-2 text-gray-700"> | |
| <li>Upload an image using the upload button</li> | |
| <li>Click and drag on the image to create rectangular annotations</li> | |
| <li>View annotation coordinates in the panel on the right</li> | |
| <li>Export coordinates when finished</li> | |
| </ol> | |
| <div class="mt-4 p-3 bg-yellow-50 border-l-4 border-yellow-400 text-yellow-700"> | |
| <p><strong>Note:</strong> Coordinates are normalized (0-1) relative to image dimensions with 6 decimal precision.</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div> | |
| <div class="bg-white rounded-xl shadow-md overflow-hidden sticky top-4"> | |
| <div class="p-4 bg-indigo-50 border-b border-indigo-100"> | |
| <h2 class="text-xl font-semibold text-indigo-700">Annotation Coordinates</h2> | |
| </div> | |
| <div class="p-4"> | |
| <div id="coordinates-list" class="space-y-3 max-h-96 overflow-y-auto"> | |
| <p class="text-gray-500 italic" id="empty-state">No annotations yet. Draw rectangles on the image to see coordinates here.</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| document.addEventListener('DOMContentLoaded', function() { | |
| feather.replace(); | |
| const imageCanvas = document.getElementById('image-canvas'); | |
| const drawingCanvas = document.getElementById('drawing-canvas'); | |
| const imageCtx = imageCanvas.getContext('2d'); | |
| const drawingCtx = drawingCanvas.getContext('2d'); | |
| const fileInput = document.getElementById('file-input'); | |
| const uploadBtn = document.getElementById('upload-btn'); | |
| const clearBtn = document.getElementById('clear-btn'); | |
| const exportBtn = document.getElementById('export-btn'); | |
| const coordinatesList = document.getElementById('coordinates-list'); | |
| const emptyState = document.getElementById('empty-state'); | |
| let image = null; | |
| let isDrawing = false; | |
| let startX = 0; | |
| let startY = 0; | |
| let annotations = []; | |
| // Set canvas container size | |
| const canvasContainer = document.getElementById('canvas-container'); | |
| canvasContainer.style.width = '100%'; | |
| canvasContainer.style.height = 'auto'; | |
| // Initialize canvases | |
| function initCanvases() { | |
| if (!image) return; | |
| const containerWidth = canvasContainer.offsetWidth; | |
| const scale = containerWidth / image.width; | |
| const height = image.height * scale; | |
| imageCanvas.width = containerWidth; | |
| imageCanvas.height = height; | |
| drawingCanvas.width = containerWidth; | |
| drawingCanvas.height = height; | |
| imageCtx.drawImage(image, 0, 0, containerWidth, height); | |
| } | |
| // Handle image upload | |
| uploadBtn.addEventListener('click', function() { | |
| fileInput.click(); | |
| }); | |
| fileInput.addEventListener('change', function(e) { | |
| if (!e.target.files.length) return; | |
| const file = e.target.files[0]; | |
| if (!file || !file.type.match('image.*')) { | |
| alert('Please select a valid image file (JPEG, PNG, etc.)'); | |
| return; | |
| } | |
| // Show loading state | |
| uploadBtn.disabled = true; | |
| uploadBtn.innerHTML = '<i data-feather="loader" class="animate-spin"></i> Uploading...'; | |
| feather.replace(); | |
| const reader = new FileReader(); | |
| reader.onload = function(event) { | |
| // Reset button state | |
| uploadBtn.disabled = false; | |
| uploadBtn.innerHTML = '<i data-feather="upload"></i> Upload Image'; | |
| feather.replace(); | |
| image = new Image(); | |
| image.onload = function() { | |
| initCanvases(); | |
| annotations = []; | |
| updateCoordinatesList(); | |
| // Show success toast | |
| const toast = document.createElement('div'); | |
| toast.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg'; | |
| toast.textContent = 'Image uploaded successfully!'; | |
| document.body.appendChild(toast); | |
| setTimeout(() => toast.remove(), 3000); | |
| }; | |
| image.onerror = function() { | |
| uploadBtn.disabled = false; | |
| uploadBtn.innerHTML = '<i data-feather="upload"></i> Upload Image'; | |
| feather.replace(); | |
| alert('Error loading image. Please try another file.'); | |
| }; | |
| image.src = event.target.result; | |
| }; | |
| reader.readAsDataURL(file); | |
| }); | |
| // Drawing functionality | |
| drawingCanvas.addEventListener('mousedown', (e) => { | |
| if (!image) return; | |
| isDrawing = true; | |
| startX = e.offsetX; | |
| startY = e.offsetY; | |
| }); | |
| drawingCanvas.addEventListener('mousemove', (e) => { | |
| if (!isDrawing || !image) return; | |
| drawingCtx.clearRect(0, 0, drawingCanvas.width, drawingCanvas.height); | |
| const currentX = e.offsetX; | |
| const currentY = e.offsetY; | |
| drawingCtx.strokeStyle = '#3B82F6'; | |
| drawingCtx.lineWidth = 2; | |
| drawingCtx.setLineDash([5, 5]); | |
| drawingCtx.strokeRect( | |
| startX, | |
| startY, | |
| currentX - startX, | |
| currentY - startY | |
| ); | |
| drawingCtx.fillStyle = 'rgba(59, 130, 246, 0.1)'; | |
| drawingCtx.fillRect( | |
| startX, | |
| startY, | |
| currentX - startX, | |
| currentY - startY | |
| ); | |
| }); | |
| drawingCanvas.addEventListener('mouseup', (e) => { | |
| if (!isDrawing || !image) return; | |
| isDrawing = false; | |
| drawingCtx.clearRect(0, 0, drawingCanvas.width, drawingCanvas.height); | |
| const endX = e.offsetX; | |
| const endY = e.offsetY; | |
| // Don't save zero-size rectangles | |
| if (Math.abs(endX - startX) < 5 || Math.abs(endY - startY) < 5) return; | |
| // Calculate normalized coordinates (0-1) | |
| const width = imageCanvas.width; | |
| const height = imageCanvas.height; | |
| const x1 = (Math.min(startX, endX) / width).toFixed(6); | |
| const y1 = (Math.min(startY, endY) / height).toFixed(6); | |
| const x2 = (Math.max(startX, endX) / width).toFixed(6); | |
| const y2 = (Math.max(startY, endY) / height).toFixed(6); | |
| annotations.push({ x1, y1, x2, y2 }); | |
| updateCoordinatesList(); | |
| drawAllAnnotations(); | |
| }); | |
| // Draw all saved annotations | |
| function drawAllAnnotations() { | |
| drawingCtx.clearRect(0, 0, drawingCanvas.width, drawingCanvas.height); | |
| annotations.forEach(rect => { | |
| const width = imageCanvas.width; | |
| const height = imageCanvas.height; | |
| const x1 = parseFloat(rect.x1) * width; | |
| const y1 = parseFloat(rect.y1) * height; | |
| const x2 = parseFloat(rect.x2) * width; | |
| const y2 = parseFloat(rect.y2) * height; | |
| drawingCtx.strokeStyle = '#3B82F6'; | |
| drawingCtx.lineWidth = 2; | |
| drawingCtx.setLineDash([]); | |
| drawingCtx.strokeRect(x1, y1, x2 - x1, y2 - y1); | |
| drawingCtx.fillStyle = 'rgba(59, 130, 246, 0.1)'; | |
| drawingCtx.fillRect(x1, y1, x2 - x1, y2 - y1); | |
| }); | |
| } | |
| // Update coordinates list in the UI | |
| function updateCoordinatesList() { | |
| if (annotations.length === 0) { | |
| emptyState.style.display = 'block'; | |
| return; | |
| } | |
| emptyState.style.display = 'none'; | |
| coordinatesList.innerHTML = ''; | |
| annotations.forEach((rect, index) => { | |
| const item = document.createElement('div'); | |
| item.className = 'annotation-item p-3 border border-gray-200 rounded-lg'; | |
| item.innerHTML = ` | |
| <div class="flex justify-between items-start mb-1"> | |
| <span class="font-medium text-indigo-600">Annotation #${index + 1}</span> | |
| <button class="text-red-500 hover:text-red-700 delete-btn" data-index="${index}"> | |
| <i data-feather="x" class="w-4 h-4"></i> | |
| </button> | |
| </div> | |
| <div class="coordinates-display text-sm text-gray-700"> | |
| <div>x1: ${rect.x1}</div> | |
| <div>y1: ${rect.y1}</div> | |
| <div>x2: ${rect.x2}</div> | |
| <div>y2: ${rect.y2}</div> | |
| </div> | |
| `; | |
| coordinatesList.appendChild(item); | |
| // Add delete event to button | |
| item.querySelector('.delete-btn').addEventListener('click', function() { | |
| const indexToRemove = parseInt(this.getAttribute('data-index')); | |
| annotations.splice(indexToRemove, 1); | |
| updateCoordinatesList(); | |
| drawAllAnnotations(); | |
| }); | |
| }); | |
| feather.replace(); | |
| } | |
| // Clear all annotations | |
| clearBtn.addEventListener('click', function() { | |
| if (!image || annotations.length === 0) return; | |
| if (confirm('Are you sure you want to clear all annotations?')) { | |
| annotations = []; | |
| drawingCtx.clearRect(0, 0, drawingCanvas.width, drawingCanvas.height); | |
| updateCoordinatesList(); | |
| } | |
| }); | |
| // Export coordinates | |
| exportBtn.addEventListener('click', function() { | |
| if (!image || annotations.length === 0) { | |
| alert('No annotations to export!'); | |
| return; | |
| } | |
| let exportData = annotations.map((rect, index) => ({ | |
| id: index + 1, | |
| coordinates: { | |
| x1: rect.x1, | |
| y1: rect.y1, | |
| x2: rect.x2, | |
| y2: rect.y2 | |
| } | |
| })); | |
| const dataStr = JSON.stringify(exportData, null, 2); | |
| const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr); | |
| const exportName = 'annotations_' + new Date().toISOString().slice(0, 10) + '.json'; | |
| const linkElement = document.createElement('a'); | |
| linkElement.setAttribute('href', dataUri); | |
| linkElement.setAttribute('download', exportName); | |
| linkElement.click(); | |
| }); | |
| // Handle window resize | |
| window.addEventListener('resize', function() { | |
| if (image) { | |
| initCanvases(); | |
| drawAllAnnotations(); | |
| } | |
| }); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |