Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>📸 Comic Panel Annotator</title> | |
| <style> | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | |
| background: #f8fafc; | |
| min-height: 100vh; | |
| color: #1a202c; | |
| } | |
| /* Top Navigation Bar */ | |
| .top-nav { | |
| background: white; | |
| border-bottom: 1px solid #e2e8f0; | |
| padding: 16px 24px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| position: sticky; | |
| top: 0; | |
| z-index: 100; | |
| box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); | |
| } | |
| .logo { | |
| font-size: 20px; | |
| font-weight: 700; | |
| color: #2d3748; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .nav-actions { | |
| display: flex; | |
| gap: 12px; | |
| align-items: center; | |
| } | |
| /* Main Layout */ | |
| .main-container { | |
| display: flex; | |
| height: calc(100vh - 72px); | |
| overflow: hidden; | |
| } | |
| /* Left Sidebar */ | |
| .sidebar { | |
| width: 320px; | |
| background: white; | |
| border-right: 1px solid #e2e8f0; | |
| display: flex; | |
| flex-direction: column; | |
| overflow-y: auto; | |
| } | |
| .sidebar-section { | |
| padding: 20px; | |
| border-bottom: 1px solid #f1f5f9; | |
| } | |
| .sidebar-section:last-child { | |
| border-bottom: none; | |
| } | |
| .section-title { | |
| font-size: 14px; | |
| font-weight: 600; | |
| color: #4a5568; | |
| margin-bottom: 12px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| } | |
| /* Canvas Area */ | |
| .canvas-area { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| background: #f8fafc; | |
| position: relative; | |
| } | |
| .canvas-toolbar { | |
| background: white; | |
| border-bottom: 1px solid #e2e8f0; | |
| padding: 12px 20px; | |
| display: flex; | |
| align-items: center; | |
| gap: 16px; | |
| } | |
| .canvas-container { | |
| flex: 1; | |
| overflow: auto; | |
| display: flex; | |
| /* justify-content: center; */ | |
| align-items: flex-start; | |
| padding: 20px; | |
| width: fit-content; | |
| } | |
| canvas { | |
| border: 2px solid #e2e8f0; | |
| border-radius: 8px; | |
| cursor: crosshair; | |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); | |
| background: white; | |
| width: 100%; | |
| } | |
| canvas:hover { | |
| border-color: #4299e1; | |
| } | |
| .canvas-placeholder { | |
| text-align: center; | |
| padding: 60px 40px; | |
| color: #a0aec0; | |
| background: white; | |
| border-radius: 12px; | |
| border: 2px dashed #e2e8f0; | |
| } | |
| /* Form Elements */ | |
| /* .form-field { | |
| margin-bottom: 16px; | |
| } */ | |
| .form-label { | |
| display: block; | |
| font-size: 13px; | |
| font-weight: 500; | |
| color: #4a5568; | |
| margin-bottom: 6px; | |
| } | |
| .form-select, | |
| .form-input { | |
| width: 100%; | |
| padding: 10px 12px; | |
| border: 1px solid #d1d5db; | |
| border-radius: 6px; | |
| font-size: 14px; | |
| background: white; | |
| transition: all 0.2s; | |
| } | |
| .form-select:focus, | |
| .form-input:focus { | |
| outline: none; | |
| border-color: #4299e1; | |
| box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.1); | |
| } | |
| /* Buttons */ | |
| .btn { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| padding: 8px 16px; | |
| border: none; | |
| border-radius: 6px; | |
| font-size: 13px; | |
| font-weight: 500; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| text-decoration: none; | |
| justify-content: center; | |
| } | |
| .btn:disabled { | |
| opacity: 0.5; | |
| cursor: not-allowed; | |
| } | |
| .btn-primary { | |
| background: #4299e1; | |
| color: white; | |
| } | |
| .btn-primary:hover:not(:disabled) { | |
| background: #3182ce; | |
| } | |
| .btn-secondary { | |
| background: #718096; | |
| color: white; | |
| } | |
| .btn-secondary:hover:not(:disabled) { | |
| background: #4a5568; | |
| } | |
| .btn-success { | |
| background: #48bb78; | |
| color: white; | |
| } | |
| .btn-success:hover:not(:disabled) { | |
| background: #38a169; | |
| } | |
| .btn-danger { | |
| background: #f56565; | |
| color: white; | |
| } | |
| .btn-danger:hover:not(:disabled) { | |
| background: #e53e3e; | |
| } | |
| .btn-ghost { | |
| background: transparent; | |
| color: #4a5568; | |
| border: 1px solid #e2e8f0; | |
| } | |
| .btn-ghost:hover:not(:disabled) { | |
| background: #f7fafc; | |
| border-color: #cbd5e0; | |
| } | |
| .btn-sm { | |
| padding: 6px 12px; | |
| font-size: 12px; | |
| } | |
| .btn-block { | |
| width: 100%; | |
| } | |
| /* Navigation Controls */ | |
| .image-nav { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| background: #f7fafc; | |
| padding: 12px; | |
| border-radius: 8px; | |
| } | |
| .nav-counter { | |
| font-size: 13px; | |
| font-weight: 500; | |
| color: #4a5568; | |
| min-width: 80px; | |
| text-align: center; | |
| word-break: break-word; | |
| } | |
| /* Progress Indicator */ | |
| .progress-section { | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| color: white; | |
| padding: 20px; | |
| border-radius: 8px; | |
| margin-bottom: 16px; | |
| } | |
| .progress-stat { | |
| display: flex; | |
| justify-content: space-between; | |
| margin-bottom: 8px; | |
| font-size: 14px; | |
| } | |
| .progress-bar { | |
| width: 100%; | |
| height: 6px; | |
| background: rgba(255, 255, 255, 0.3); | |
| border-radius: 3px; | |
| overflow: hidden; | |
| margin-top: 12px; | |
| } | |
| .progress-fill { | |
| height: 100%; | |
| background: #48bb78; | |
| transition: width 0.3s ease; | |
| } | |
| /* Info Cards */ | |
| .info-card { | |
| background: #f7fafc; | |
| border: 1px solid #e2e8f0; | |
| border-radius: 8px; | |
| padding: 16px; | |
| } | |
| .info-row { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| padding: 8px 0; | |
| border-bottom: 1px solid #e2e8f0; | |
| } | |
| .info-row:last-child { | |
| border-bottom: none; | |
| } | |
| .info-label { | |
| font-size: 13px; | |
| color: #4a5568; | |
| font-weight: 500; | |
| } | |
| .info-value { | |
| font-size: 13px; | |
| color: #1a202c; | |
| font-weight: 600; | |
| } | |
| /* File Upload */ | |
| .file-upload { | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .file-upload input[type=file] { | |
| position: absolute; | |
| opacity: 0; | |
| left: -9999px; | |
| } | |
| .file-upload-label { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 8px; | |
| padding: 32px 20px; | |
| border: 2px dashed #cbd5e0; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| color: #4a5568; | |
| } | |
| .file-upload-label:hover { | |
| border-color: #4299e1; | |
| background: #f7fafc; | |
| } | |
| /* Alerts */ | |
| .alerts { | |
| position: fixed; | |
| top: 80px; | |
| right: 20px; | |
| z-index: 1000; | |
| } | |
| .alert { | |
| padding: 12px 16px; | |
| border-radius: 6px; | |
| margin-bottom: 8px; | |
| font-size: 14px; | |
| font-weight: 500; | |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); | |
| animation: slideIn 0.3s ease; | |
| } | |
| .alert-success { | |
| background: #c6f6d5; | |
| color: #22543d; | |
| border: 1px solid #9ae6b4; | |
| } | |
| .alert-error { | |
| background: #fed7d7; | |
| color: #742a2a; | |
| border: 1px solid #fc8181; | |
| } | |
| .alert-info { | |
| background: #bee3f8; | |
| color: #2a4365; | |
| border: 1px solid #90cdf4; | |
| } | |
| @keyframes slideIn { | |
| from { | |
| transform: translateX(100%); | |
| opacity: 0; | |
| } | |
| to { | |
| transform: translateX(0); | |
| opacity: 1; | |
| } | |
| } | |
| /* Quick Help */ | |
| .help-section { | |
| background: #fffbf0; | |
| border: 1px solid #fbd38d; | |
| border-radius: 8px; | |
| padding: 16px; | |
| } | |
| .help-item { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| margin-bottom: 8px; | |
| font-size: 12px; | |
| color: #744210; | |
| } | |
| .help-item:last-child { | |
| margin-bottom: 0; | |
| } | |
| .kbd { | |
| background: #2d3748; | |
| color: white; | |
| padding: 2px 6px; | |
| border-radius: 3px; | |
| font-family: monospace; | |
| font-size: 11px; | |
| } | |
| /* Responsive */ | |
| @media (max-width: 1024px) { | |
| .sidebar { | |
| width: 280px; | |
| } | |
| } | |
| @media (max-width: 768px) { | |
| .main-container { | |
| flex-direction: column; | |
| } | |
| .sidebar { | |
| width: 100%; | |
| height: auto; | |
| max-height: 300px; | |
| } | |
| .canvas-area { | |
| height: calc(100vh - 372px); | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Top Navigation --> | |
| <div class="top-nav"> | |
| <div class="logo"> | |
| 📸 Comic Panel Annotator | |
| </div> | |
| <div class="nav-actions"> | |
| <button class="btn btn-success btn-sm" id="saveBtn"> | |
| 💾 Save | |
| </button> | |
| <button class="btn btn-secondary btn-sm" id="undoBtn"> | |
| ↩️ Undo | |
| </button> | |
| <button class="btn btn-danger btn-sm" id="clearBtn"> | |
| 🗑️ Clear All | |
| </button> | |
| </div> | |
| </div> | |
| <div class="main-container"> | |
| <!-- Left Sidebar --> | |
| <div class="sidebar"> | |
| <!-- Image Selection --> | |
| <div class="sidebar-section"> | |
| <div class="section-title">Image Selection</div> | |
| <div class="image-nav"> | |
| <button class="btn btn-ghost btn-sm" id="prevBtn" disabled> | |
| ← Prev | |
| </button> | |
| <!-- <div class="nav-counter" id="currentImageDisplay"> | |
| No image | |
| </div> --> | |
| <div class="form-field"> | |
| <select class="form-select" id="imageSelect"> | |
| <option value="">Choose an image...</option> | |
| </select> | |
| </div> | |
| <button class="btn btn-ghost btn-sm" id="nextBtn" disabled> | |
| Next → | |
| </button> | |
| </div> | |
| <div class="file-upload"> | |
| <input type="file" id="uploadFile" accept="image/*"> | |
| <label for="uploadFile" class="file-upload-label"> | |
| 📤 Drop or click to upload | |
| </label> | |
| </div> | |
| </div> | |
| <!-- Progress --> | |
| <div class="sidebar-section"> | |
| <div class="progress-section"> | |
| <h3 style="margin-bottom: 12px; font-size: 16px;">Progress</h3> | |
| <div class="progress-stat"> | |
| <span>Annotated</span> | |
| <span><span id="annotatedImages">0</span>/<span id="totalImages">0</span></span> | |
| </div> | |
| <div class="progress-bar"> | |
| <div class="progress-fill" id="progressFill" style="width: 0%"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Current Image Info --> | |
| <div class="sidebar-section" id="currentImageInfo" style="display: none;"> | |
| <div class="section-title">Current Image</div> | |
| <div class="info-card"> | |
| <div class="info-row"> | |
| <span class="info-label">Boxes</span> | |
| <span class="info-value" id="boxCount">0</span> | |
| </div> | |
| <div class="info-row"> | |
| <span class="info-label">Size</span> | |
| <span class="info-value" id="imageSize">-</span> | |
| </div> | |
| <div class="info-row"> | |
| <span class="info-label">Selected</span> | |
| <span class="info-value" id="selectedBoxInfo">None</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Quick Actions --> | |
| <div class="sidebar-section"> | |
| <div class="section-title">Actions</div> | |
| <button class="btn btn-primary btn-block" id="reloadBtn"> | |
| 🔄 Reload Annotations | |
| </button> | |
| <button class="btn btn-primary btn-block" id="detectBtn" style="display: none; margin-top: 8px;"> | |
| 🔄 Detect Annotations | |
| </button> | |
| <button class="btn btn-secondary btn-block" id="downloadBtn" style="display: none; margin-top: 8px;"> | |
| 📥 Download | |
| </button> | |
| </div> | |
| <!-- Quick Help --> | |
| <div class="sidebar-section"> | |
| <div class="section-title">Quick Help</div> | |
| <div class="help-section"> | |
| <div class="help-item"> | |
| <strong>Draw:</strong> Click & drag on image | |
| </div> | |
| <div class="help-item"> | |
| <strong>Move:</strong> <span class="kbd">↑↓←→</span> keys | |
| </div> | |
| <div class="help-item"> | |
| <strong>Resize:</strong> <span class="kbd">Shift</span> + arrows | |
| </div> | |
| <div class="help-item"> | |
| <strong>Delete:</strong> <span class="kbd">Del</span> key | |
| </div> | |
| <div class="help-item"> | |
| <strong>Colors:</strong> 🟢 Saved, 🔴 New, 🔵 Selected | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Canvas Area --> | |
| <div class="canvas-area"> | |
| <div class="canvas-toolbar"> | |
| <span id="file_name" style="font-size: 13px; color: #4a5568;"> | |
| Click and drag to create annotation boxes • Select boxes to move or resize | |
| </span> | |
| </div> | |
| <div class="canvas-container"> | |
| <canvas id="annotationCanvas" style="display: none;"></canvas> | |
| <div id="canvasPlaceholder" class="canvas-placeholder"> | |
| <h3 style="margin-bottom: 8px;">Select an image to start annotating</h3> | |
| <p>Choose from the dropdown or upload a new image</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Alerts Container --> | |
| <div class="alerts" id="alerts"></div> | |
| <script> | |
| class ComicAnnotator { | |
| constructor() { | |
| this.canvas = document.getElementById('annotationCanvas'); | |
| this.ctx = this.canvas.getContext('2d'); | |
| this.boxes = []; | |
| this.images = []; | |
| this.currentImageIndex = -1; | |
| this.currentImage = null; | |
| this.backgroundImage = null; | |
| this.originalWidth = 0; | |
| this.originalHeight = 0; | |
| // Drawing state | |
| this.isDrawing = false; | |
| this.startX = 0; | |
| this.startY = 0; | |
| this.currentBox = null; | |
| // Box editing state | |
| this.selectedBoxIndex = -1; | |
| this.isDragging = false; | |
| this.isResizing = false; | |
| this.dragStartX = 0; | |
| this.dragStartY = 0; | |
| this.resizeHandle = null; | |
| this.lastMouseX = 0; | |
| this.lastMouseY = 0; | |
| this.init(); | |
| } | |
| init() { | |
| this.setupEventListeners(); | |
| this.loadImages(); | |
| } | |
| setupEventListeners() { | |
| // Image selection | |
| document.getElementById('imageSelect').addEventListener('change', (e) => { | |
| if (e.target.value) { | |
| const index = this.images.findIndex(img => img.name === e.target.value); | |
| if (index >= 0) { | |
| this.currentImageIndex = index; | |
| this.loadImage(e.target.value); | |
| } | |
| } | |
| }); | |
| // Navigation buttons | |
| document.getElementById('prevBtn').addEventListener('click', () => this.navigatePrevious()); | |
| document.getElementById('nextBtn').addEventListener('click', () => this.navigateNext()); | |
| // File upload | |
| document.getElementById('uploadFile').addEventListener('change', (e) => { | |
| if (e.target.files[0]) { | |
| this.uploadImage(e.target.files[0]); | |
| } | |
| }); | |
| // Action buttons | |
| document.getElementById('saveBtn').addEventListener('click', () => this.saveAnnotations()); | |
| document.getElementById('undoBtn').addEventListener('click', () => this.undoLastBox()); | |
| document.getElementById('clearBtn').addEventListener('click', () => this.clearAllBoxes()); | |
| document.getElementById('reloadBtn').addEventListener('click', () => this.reloadAnnotations()); | |
| document.getElementById('detectBtn').addEventListener('click', () => this.detectAnnotations()); | |
| document.getElementById('downloadBtn').addEventListener('click', () => this.downloadAnnotations()); | |
| // Canvas events | |
| this.canvas.addEventListener('mousedown', (e) => this.onMouseDown(e)); | |
| this.canvas.addEventListener('mousemove', (e) => this.onMouseMove(e)); | |
| this.canvas.addEventListener('mouseup', () => this.onMouseUp()); | |
| this.canvas.addEventListener('mouseleave', () => this.onMouseUp()); | |
| // Keyboard events | |
| document.addEventListener('keydown', (e) => this.onKeyDown(e)); | |
| // Make canvas focusable for keyboard events | |
| this.canvas.tabIndex = 0; | |
| } | |
| async loadImages() { | |
| try { | |
| const response = await fetch('/api/annotate/images'); | |
| this.images = await response.json(); | |
| const select = document.getElementById('imageSelect'); | |
| select.innerHTML = '<option value="">Choose an image...</option>'; | |
| this.images.forEach(img => { | |
| const option = document.createElement('option'); | |
| option.value = img.name; | |
| option.textContent = `${img.name} ${img.has_annotations ? '✓' : ''}`; | |
| select.appendChild(option); | |
| }); | |
| // Update progress | |
| const annotated = this.images.filter(img => img.has_annotations).length; | |
| document.getElementById('totalImages').textContent = this.images.length; | |
| document.getElementById('annotatedImages').textContent = annotated; | |
| const progress = this.images.length > 0 ? (annotated / this.images.length) * 100 : 0; | |
| document.getElementById('progressFill').style.width = progress + '%'; | |
| this.updateNavigationButtons(); | |
| } catch (error) { | |
| this.showAlert('Error loading images: ' + error.message, 'error'); | |
| } | |
| } | |
| updateNavigationButtons() { | |
| const prevBtn = document.getElementById('prevBtn'); | |
| const nextBtn = document.getElementById('nextBtn'); | |
| prevBtn.disabled = this.currentImageIndex <= 0; | |
| nextBtn.disabled = this.currentImageIndex >= this.images.length - 1; | |
| // if (this.currentImageIndex >= 0) { | |
| // const currentImageName = this.images[this.currentImageIndex].name; | |
| // document.getElementById('currentImageDisplay').textContent = `${this.currentImageIndex + 1}/${this.images.length}: ${currentImageName}`; | |
| // } else { | |
| // document.getElementById('currentImageDisplay').textContent = 'No image selected'; | |
| // } | |
| } | |
| navigatePrevious() { | |
| if (this.currentImageIndex > 0) { | |
| this.currentImageIndex--; | |
| const imageName = this.images[this.currentImageIndex].name; | |
| document.getElementById('imageSelect').value = imageName; | |
| document.getElementById('file_name').innerText = imageName; | |
| this.loadImage(imageName); | |
| } | |
| } | |
| navigateNext() { | |
| if (this.currentImageIndex < this.images.length - 1) { | |
| this.currentImageIndex++; | |
| const imageName = this.images[this.currentImageIndex].name; | |
| document.getElementById('imageSelect').value = imageName; | |
| document.getElementById('file_name').innerText = imageName; | |
| this.loadImage(imageName); | |
| } | |
| } | |
| async loadImage(imageName) { | |
| try { | |
| // Load image data | |
| const imageResponse = await fetch(`/api/annotate/image/${encodeURIComponent(imageName)}`); | |
| const imageData = await imageResponse.json(); | |
| // Load annotations | |
| const annotationsResponse = await fetch(`/api/annotate/annotations/${encodeURIComponent(imageName)}`); | |
| const annotationsData = await annotationsResponse.json(); | |
| this.currentImage = imageName; | |
| this.originalWidth = imageData.width; | |
| this.originalHeight = imageData.height; | |
| this.boxes = annotationsData.boxes || []; | |
| this.selectedBoxIndex = -1; | |
| // Load and draw image | |
| const img = new Image(); | |
| img.onload = () => { | |
| this.backgroundImage = img; | |
| // Set canvas size to match image | |
| this.canvas.width = img.width; | |
| this.canvas.height = img.height; | |
| this.drawCanvas(); | |
| document.getElementById('canvasPlaceholder').style.display = 'none'; | |
| this.canvas.style.display = 'block'; | |
| document.getElementById('downloadBtn').style.display = 'block'; | |
| document.getElementById('detectBtn').style.display = 'block'; | |
| }; | |
| img.src = imageData.image_data; | |
| // Update info panel | |
| document.getElementById('currentImageInfo').style.display = 'block'; | |
| document.getElementById('boxCount').textContent = this.boxes.length; | |
| document.getElementById('imageSize').textContent = `${imageData.width}×${imageData.height}`; | |
| document.getElementById('selectedBoxInfo').textContent = 'None'; | |
| this.updateNavigationButtons(); | |
| } catch (error) { | |
| this.showAlert('Error loading image: ' + error.message, 'error'); | |
| } | |
| } | |
| drawCanvas() { | |
| this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); | |
| // Draw background image | |
| if (this.backgroundImage) { | |
| this.ctx.drawImage(this.backgroundImage, 0, 0); | |
| } | |
| // Draw existing boxes | |
| this.boxes.forEach((box, index) => { | |
| let strokeColor = '#00ff00'; | |
| let fillColor = 'rgba(0, 255, 0, 0.2)'; | |
| if (index === this.selectedBoxIndex) { | |
| strokeColor = '#0066ff'; | |
| fillColor = 'rgba(0, 102, 255, 0.3)'; | |
| } else if (!box.saved) { | |
| strokeColor = '#ff0000'; | |
| fillColor = 'rgba(255, 0, 0, 0.2)'; | |
| } | |
| this.drawBox(box.left, box.top, box.width, box.height, strokeColor, fillColor); | |
| // Draw resize handles for selected box | |
| if (index === this.selectedBoxIndex) { | |
| this.drawResizeHandles(box); | |
| } | |
| }); | |
| // Draw current box being drawn | |
| if (this.currentBox) { | |
| this.drawBox(this.currentBox.left, this.currentBox.top, | |
| this.currentBox.width, this.currentBox.height, '#ff0000', 'rgba(255, 0, 0, 0.3)'); | |
| } | |
| } | |
| drawBox(x, y, width, height, strokeColor, fillColor) { | |
| this.ctx.strokeStyle = strokeColor; | |
| this.ctx.fillStyle = fillColor; | |
| this.ctx.lineWidth = 3; | |
| this.ctx.fillRect(x, y, width, height); | |
| this.ctx.strokeRect(x, y, width, height); | |
| } | |
| drawResizeHandles(box) { | |
| const handleSize = 10; | |
| const handles = [ | |
| { x: box.left - handleSize / 2, y: box.top - handleSize / 2, cursor: 'nw-resize' }, | |
| { x: box.left + box.width - handleSize / 2, y: box.top - handleSize / 2, cursor: 'ne-resize' }, | |
| { x: box.left - handleSize / 2, y: box.top + box.height - handleSize / 2, cursor: 'sw-resize' }, | |
| { x: box.left + box.width - handleSize / 2, y: box.top + box.height - handleSize / 2, cursor: 'se-resize' }, | |
| { x: box.left + box.width / 2 - handleSize / 2, y: box.top - handleSize / 2, cursor: 'n-resize' }, | |
| { x: box.left + box.width / 2 - handleSize / 2, y: box.top + box.height - handleSize / 2, cursor: 's-resize' }, | |
| { x: box.left - handleSize / 2, y: box.top + box.height / 2 - handleSize / 2, cursor: 'w-resize' }, | |
| { x: box.left + box.width - handleSize / 2, y: box.top + box.height / 2 - handleSize / 2, cursor: 'e-resize' } | |
| ]; | |
| this.ctx.fillStyle = '#0066ff'; | |
| this.ctx.strokeStyle = '#ffffff'; | |
| this.ctx.lineWidth = 4; | |
| handles.forEach(handle => { | |
| this.ctx.fillRect(handle.x, handle.y, handleSize, handleSize); | |
| this.ctx.strokeRect(handle.x, handle.y, handleSize, handleSize); | |
| }); | |
| } | |
| onMouseDown(e) { | |
| if (!this.currentImage) return; | |
| const pos = this.getMousePos(e); | |
| this.lastMouseX = pos.x; | |
| this.lastMouseY = pos.y; | |
| // Check if clicking on a resize handle | |
| if (this.selectedBoxIndex >= 0) { | |
| const handle = this.getResizeHandle(pos.x, pos.y); | |
| if (handle) { | |
| this.isResizing = true; | |
| this.resizeHandle = handle; | |
| this.canvas.style.cursor = handle.cursor; | |
| return; | |
| } | |
| } | |
| // Check if clicking on an existing box | |
| const clickedBoxIndex = this.getBoxAtPosition(pos.x, pos.y); | |
| if (clickedBoxIndex >= 0) { | |
| this.selectedBoxIndex = clickedBoxIndex; | |
| this.isDragging = true; | |
| this.dragStartX = pos.x; | |
| this.dragStartY = pos.y; | |
| this.canvas.style.cursor = 'move'; | |
| this.updateSelectedBoxInfo(); | |
| this.drawCanvas(); | |
| return; | |
| } | |
| // Start drawing new box | |
| this.selectedBoxIndex = -1; | |
| this.startX = pos.x; | |
| this.startY = pos.y; | |
| this.isDrawing = true; | |
| this.currentBox = { left: this.startX, top: this.startY, width: 0, height: 0 }; | |
| this.updateSelectedBoxInfo(); | |
| } | |
| onMouseMove(e) { | |
| if (!this.currentImage) return; | |
| const pos = this.getMousePos(e); | |
| if (this.isResizing && this.selectedBoxIndex >= 0) { | |
| this.resizeBox(pos.x, pos.y); | |
| this.drawCanvas(); | |
| } else if (this.isDragging && this.selectedBoxIndex >= 0) { | |
| const deltaX = pos.x - this.lastMouseX; | |
| const deltaY = pos.y - this.lastMouseY; | |
| this.moveBox(this.selectedBoxIndex, deltaX, deltaY); | |
| this.lastMouseX = pos.x; | |
| this.lastMouseY = pos.y; | |
| this.drawCanvas(); | |
| } else if (this.isDrawing) { | |
| this.currentBox.width = pos.x - this.startX; | |
| this.currentBox.height = pos.y - this.startY; | |
| this.drawCanvas(); | |
| } else { | |
| // Update cursor based on what's under mouse | |
| this.updateCursor(pos.x, pos.y); | |
| } | |
| } | |
| onMouseUp() { | |
| if (this.isDrawing && this.currentBox) { | |
| // Only add box if it has meaningful size | |
| if (Math.abs(this.currentBox.width) > 10 && Math.abs(this.currentBox.height) > 10) { | |
| let left = Math.min(this.startX, this.startX + this.currentBox.width); | |
| let top = Math.min(this.startY, this.startY + this.currentBox.height); | |
| let width = Math.abs(this.currentBox.width); | |
| let height = Math.abs(this.currentBox.height); | |
| // Apply boundary conditions | |
| left = Math.max(0, left); | |
| top = Math.max(0, top); | |
| width = Math.min(width, this.originalWidth - left); | |
| height = Math.min(height, this.originalHeight - top); | |
| // Only create box if it still has valid dimensions after boundary check | |
| if (width > 10 && height > 10) { | |
| const box = { | |
| left: left, | |
| top: top, | |
| width: width, | |
| height: height, | |
| type: 'rect', | |
| stroke: '#ff0000', | |
| strokeWidth: 3, | |
| fill: 'rgba(255, 0, 0, 0.3)', | |
| saved: false | |
| }; | |
| this.boxes.push(box); | |
| this.selectedBoxIndex = this.boxes.length - 1; | |
| document.getElementById('boxCount').textContent = this.boxes.length; | |
| this.updateSelectedBoxInfo(); | |
| } | |
| } | |
| this.currentBox = null; | |
| } | |
| this.isDrawing = false; | |
| this.isDragging = false; | |
| this.isResizing = false; | |
| this.resizeHandle = null; | |
| this.canvas.style.cursor = 'crosshair'; | |
| this.drawCanvas(); | |
| } | |
| updateCursor(x, y) { | |
| if (this.selectedBoxIndex >= 0) { | |
| const handle = this.getResizeHandle(x, y); | |
| if (handle) { | |
| this.canvas.style.cursor = handle.cursor; | |
| return; | |
| } | |
| } | |
| const boxIndex = this.getBoxAtPosition(x, y); | |
| if (boxIndex >= 0) { | |
| this.canvas.style.cursor = 'pointer'; | |
| } else { | |
| this.canvas.style.cursor = 'crosshair'; | |
| } | |
| } | |
| getResizeHandle(x, y) { | |
| if (this.selectedBoxIndex < 0) return null; | |
| const box = this.boxes[this.selectedBoxIndex]; | |
| const handleSize = 8; | |
| const tolerance = 5; | |
| const handles = [ | |
| { x: box.left, y: box.top, cursor: 'nw-resize', type: 'nw' }, | |
| { x: box.left + box.width, y: box.top, cursor: 'ne-resize', type: 'ne' }, | |
| { x: box.left, y: box.top + box.height, cursor: 'sw-resize', type: 'sw' }, | |
| { x: box.left + box.width, y: box.top + box.height, cursor: 'se-resize', type: 'se' }, | |
| { x: box.left + box.width / 2, y: box.top, cursor: 'n-resize', type: 'n' }, | |
| { x: box.left + box.width / 2, y: box.top + box.height, cursor: 's-resize', type: 's' }, | |
| { x: box.left, y: box.top + box.height / 2, cursor: 'w-resize', type: 'w' }, | |
| { x: box.left + box.width, y: box.top + box.height / 2, cursor: 'e-resize', type: 'e' } | |
| ]; | |
| for (let handle of handles) { | |
| if (Math.abs(x - handle.x) <= tolerance && Math.abs(y - handle.y) <= tolerance) { | |
| return handle; | |
| } | |
| } | |
| return null; | |
| } | |
| getBoxAtPosition(x, y) { | |
| for (let i = this.boxes.length - 1; i >= 0; i--) { | |
| const box = this.boxes[i]; | |
| if (x >= box.left && x <= box.left + box.width && | |
| y >= box.top && y <= box.top + box.height) { | |
| return i; | |
| } | |
| } | |
| return -1; | |
| } | |
| resizeBox(x, y) { | |
| if (this.selectedBoxIndex < 0 || !this.resizeHandle) return; | |
| const box = this.boxes[this.selectedBoxIndex]; | |
| const handle = this.resizeHandle; | |
| switch (handle.type) { | |
| case 'nw': | |
| const newWidth = box.width + (box.left - x); | |
| const newHeight = box.height + (box.top - y); | |
| const newLeft = Math.max(0, x); | |
| const newTop = Math.max(0, y); | |
| if (newWidth > 10 && newHeight > 10) { | |
| box.width = Math.min(newWidth, box.left + box.width); | |
| box.height = Math.min(newHeight, box.top + box.height); | |
| box.left = newLeft; | |
| box.top = newTop; | |
| } | |
| break; | |
| case 'ne': | |
| const neWidth = Math.min(x - box.left, this.originalWidth - box.left); | |
| const neHeight = box.height + (box.top - y); | |
| const neTop = Math.max(0, y); | |
| if (neWidth > 10 && neHeight > 10) { | |
| box.width = neWidth; | |
| box.height = Math.min(neHeight, box.top + box.height); | |
| box.top = neTop; | |
| } | |
| break; | |
| case 'sw': | |
| const swWidth = box.width + (box.left - x); | |
| const swHeight = Math.min(y - box.top, this.originalHeight - box.top); | |
| const swLeft = Math.max(0, x); | |
| if (swWidth > 10 && swHeight > 10) { | |
| box.width = Math.min(swWidth, box.left + box.width); | |
| box.height = swHeight; | |
| box.left = swLeft; | |
| } | |
| break; | |
| case 'se': | |
| const seWidth = Math.min(x - box.left, this.originalWidth - box.left); | |
| const seHeight = Math.min(y - box.top, this.originalHeight - box.top); | |
| if (seWidth > 10 && seHeight > 10) { | |
| box.width = seWidth; | |
| box.height = seHeight; | |
| } | |
| break; | |
| case 'n': | |
| const nHeight = box.height + (box.top - y); | |
| const nTop = Math.max(0, y); | |
| if (nHeight > 10) { | |
| box.height = Math.min(nHeight, box.top + box.height); | |
| box.top = nTop; | |
| } | |
| break; | |
| case 's': | |
| const sHeight = Math.min(y - box.top, this.originalHeight - box.top); | |
| if (sHeight > 10) { | |
| box.height = sHeight; | |
| } | |
| break; | |
| case 'w': | |
| const wWidth = box.width + (box.left - x); | |
| const wLeft = Math.max(0, x); | |
| if (wWidth > 10) { | |
| box.width = Math.min(wWidth, box.left + box.width); | |
| box.left = wLeft; | |
| } | |
| break; | |
| case 'e': | |
| const eWidth = Math.min(x - box.left, this.originalWidth - box.left); | |
| if (eWidth > 10) { | |
| box.width = eWidth; | |
| } | |
| break; | |
| } | |
| box.saved = false; | |
| this.updateSelectedBoxInfo(); | |
| } | |
| moveBox(boxIndex, deltaX, deltaY) { | |
| if (boxIndex < 0 || boxIndex >= this.boxes.length) return; | |
| const box = this.boxes[boxIndex]; | |
| // Calculate new position | |
| let newLeft = box.left + deltaX; | |
| let newTop = box.top + deltaY; | |
| // Apply boundary conditions | |
| newLeft = Math.max(0, Math.min(this.originalWidth - box.width, newLeft)); | |
| newTop = Math.max(0, Math.min(this.originalHeight - box.height, newTop)); | |
| // Update box position | |
| box.left = newLeft; | |
| box.top = newTop; | |
| box.saved = false; | |
| this.updateSelectedBoxInfo(); | |
| } | |
| // Modified onKeyDown method with new resize functionality | |
| onKeyDown(e) { | |
| if (!this.currentImage || this.selectedBoxIndex < 0) return; | |
| const resizeDistance = 5; // Fixed resize distance | |
| const moveDistance = e.shiftKey ? 10 : 1; | |
| if (e.shiftKey) { | |
| // Shift + arrow keys for resizing | |
| switch (e.key) { | |
| case 'ArrowLeft': | |
| e.preventDefault(); | |
| this.resizeSelectedBox(-resizeDistance, 0); | |
| break; | |
| case 'ArrowRight': | |
| e.preventDefault(); | |
| this.resizeSelectedBox(resizeDistance, 0); | |
| break; | |
| case 'ArrowUp': | |
| e.preventDefault(); | |
| this.resizeSelectedBox(0, -resizeDistance); | |
| break; | |
| case 'ArrowDown': | |
| e.preventDefault(); | |
| this.resizeSelectedBox(0, resizeDistance); | |
| break; | |
| } | |
| } else { | |
| // Regular arrow keys for moving | |
| switch (e.key) { | |
| case 'ArrowLeft': | |
| e.preventDefault(); | |
| this.moveBox(this.selectedBoxIndex, -moveDistance, 0); | |
| this.drawCanvas(); | |
| break; | |
| case 'ArrowRight': | |
| e.preventDefault(); | |
| this.moveBox(this.selectedBoxIndex, moveDistance, 0); | |
| this.drawCanvas(); | |
| break; | |
| case 'ArrowUp': | |
| e.preventDefault(); | |
| this.moveBox(this.selectedBoxIndex, 0, -moveDistance); | |
| this.drawCanvas(); | |
| break; | |
| case 'ArrowDown': | |
| e.preventDefault(); | |
| this.moveBox(this.selectedBoxIndex, 0, moveDistance); | |
| this.drawCanvas(); | |
| break; | |
| } | |
| } | |
| // Other key handlers | |
| switch (e.key) { | |
| case 'Delete': | |
| case 'Backspace': | |
| e.preventDefault(); | |
| this.deleteSelectedBox(); | |
| break; | |
| case 'Escape': | |
| e.preventDefault(); | |
| this.selectedBoxIndex = -1; | |
| this.updateSelectedBoxInfo(); | |
| this.drawCanvas(); | |
| break; | |
| } | |
| } | |
| // New method to resize selected box | |
| resizeSelectedBox(deltaWidth, deltaHeight) { | |
| if (this.selectedBoxIndex < 0) return; | |
| const box = this.boxes[this.selectedBoxIndex]; | |
| // Calculate new dimensions | |
| let newWidth = box.width + deltaWidth; | |
| let newHeight = box.height + deltaHeight; | |
| // Apply boundary conditions for width | |
| if (newWidth >= 10) { | |
| // Ensure box doesn't exceed original width | |
| newWidth = Math.min(newWidth, this.originalWidth - box.left); | |
| box.width = newWidth; | |
| // Adjust left position if needed | |
| if (box.left + box.width > this.originalWidth) { | |
| box.left = this.originalWidth - box.width; | |
| } | |
| } | |
| // Apply boundary conditions for height | |
| if (newHeight >= 10) { | |
| // Ensure box doesn't exceed original height | |
| newHeight = Math.min(newHeight, this.originalHeight - box.top); | |
| box.height = newHeight; | |
| // Adjust top position if needed | |
| if (box.top + box.height > this.originalHeight) { | |
| box.top = this.originalHeight - box.height; | |
| } | |
| } | |
| box.saved = false; | |
| this.updateSelectedBoxInfo(); | |
| this.drawCanvas(); | |
| } | |
| deleteSelectedBox() { | |
| if (this.selectedBoxIndex >= 0) { | |
| this.boxes.splice(this.selectedBoxIndex, 1); | |
| this.selectedBoxIndex = -1; | |
| document.getElementById('boxCount').textContent = this.boxes.length; | |
| this.updateSelectedBoxInfo(); | |
| this.drawCanvas(); | |
| // this.showAlert('Box deleted', 'info'); | |
| } | |
| } | |
| updateSelectedBoxInfo() { | |
| const selectedBoxInfo = document.getElementById('selectedBoxInfo'); | |
| if (this.selectedBoxIndex >= 0) { | |
| const box = this.boxes[this.selectedBoxIndex]; | |
| selectedBoxInfo.textContent = `#${this.selectedBoxIndex + 1} (${Math.round(box.left)}, ${Math.round(box.top)}, ${Math.round(box.width)}×${Math.round(box.height)})`; | |
| } else { | |
| selectedBoxInfo.textContent = 'None'; | |
| } | |
| } | |
| getMousePos(e) { | |
| const rect = this.canvas.getBoundingClientRect(); | |
| const scaleX = this.canvas.width / rect.width; | |
| const scaleY = this.canvas.height / rect.height; | |
| return { | |
| x: (e.clientX - rect.left) * scaleX, | |
| y: (e.clientY - rect.top) * scaleY | |
| }; | |
| } | |
| async saveAnnotations() { | |
| if (!this.currentImage || this.boxes.length === 0) { | |
| this.showAlert('No annotations to save', 'info'); | |
| return; | |
| } | |
| try { | |
| const response = await fetch('/api/annotate/annotations', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({ | |
| boxes: this.boxes.map(box => ({ | |
| ...box, | |
| saved: true | |
| })), | |
| image_name: this.currentImage, | |
| original_width: this.originalWidth, | |
| original_height: this.originalHeight | |
| }) | |
| }); | |
| if (response.ok) { | |
| const result = await response.json(); | |
| this.boxes.forEach(box => box.saved = true); | |
| this.drawCanvas(); | |
| this.showAlert(result.message, 'success'); | |
| this.loadImages(); // Refresh image list to update progress | |
| } else { | |
| throw new Error('Failed to save annotations'); | |
| } | |
| } catch (error) { | |
| this.showAlert('Error saving annotations: ' + error.message, 'error'); | |
| } | |
| } | |
| undoLastBox() { | |
| if (this.boxes.length > 0) { | |
| this.boxes.pop(); | |
| this.selectedBoxIndex = -1; | |
| document.getElementById('boxCount').textContent = this.boxes.length; | |
| this.updateSelectedBoxInfo(); | |
| this.drawCanvas(); | |
| this.showAlert('Last box removed', 'info'); | |
| } else { | |
| this.showAlert('No boxes to undo', 'info'); | |
| } | |
| } | |
| async clearAllBoxes() { | |
| if (!this.currentImage) return; | |
| if (confirm('Are you sure you want to clear all boxes and delete the annotation file?')) { | |
| try { | |
| // Delete annotations from server | |
| await fetch(`/api/annotate/annotations/${encodeURIComponent(this.currentImage)}`, { | |
| method: 'DELETE' | |
| }); | |
| this.boxes = []; | |
| this.selectedBoxIndex = -1; | |
| document.getElementById('boxCount').textContent = '0'; | |
| this.updateSelectedBoxInfo(); | |
| this.drawCanvas(); | |
| this.showAlert('All boxes cleared', 'success'); | |
| this.loadImages(); // Refresh progress | |
| } catch (error) { | |
| this.showAlert('Error clearing annotations: ' + error.message, 'error'); | |
| } | |
| } | |
| } | |
| async reloadAnnotations() { | |
| if (!this.currentImage) return; | |
| try { | |
| const response = await fetch(`/api/annotate/annotations/${encodeURIComponent(this.currentImage)}`); | |
| const data = await response.json(); | |
| this.boxes = (data.boxes || []).map(box => ({ | |
| ...box, | |
| saved: true | |
| })); | |
| this.selectedBoxIndex = -1; | |
| document.getElementById('boxCount').textContent = this.boxes.length; | |
| this.updateSelectedBoxInfo(); | |
| this.drawCanvas(); | |
| this.showAlert('Annotations reloaded from file', 'success'); | |
| } catch (error) { | |
| this.showAlert('Error reloading annotations: ' + error.message, 'error'); | |
| } | |
| } | |
| async detectAnnotations() { | |
| if (!this.currentImage) return; | |
| try { | |
| const response = await fetch(`/api/annotate/detect_annotations/${encodeURIComponent(this.currentImage)}`); | |
| const data = await response.json(); | |
| this.boxes = (data.boxes || []).map(box => ({ | |
| ...box, | |
| saved: true | |
| })); | |
| this.selectedBoxIndex = -1; | |
| document.getElementById('boxCount').textContent = this.boxes.length; | |
| this.updateSelectedBoxInfo(); | |
| this.drawCanvas(); | |
| this.showAlert('Annotations detected and loaded from file', 'success'); | |
| } catch (error) { | |
| this.showAlert('Error reloading annotations: ' + error.message, 'error'); | |
| } | |
| } | |
| downloadAnnotations() { | |
| if (!this.currentImage) return; | |
| const link = document.createElement('a'); | |
| link.href = `//annotate/annotations/${encodeURIComponent(this.currentImage)}/download`; | |
| link.download = `${this.currentImage.split('.')[0]}.txt`; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| } | |
| async uploadImage(file) { | |
| const formData = new FormData(); | |
| formData.append('file', file); | |
| try { | |
| const response = await fetch('/api/annotate/upload', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| if (response.ok) { | |
| const result = await response.json(); | |
| this.showAlert(result.message, 'success'); | |
| await this.loadImages(); | |
| // Auto-select the uploaded image | |
| const index = this.images.findIndex(img => img.name === file.name); | |
| if (index >= 0) { | |
| this.currentImageIndex = index; | |
| document.getElementById('imageSelect').value = file.name; | |
| this.loadImage(file.name); | |
| } | |
| } else { | |
| throw new Error('Upload failed'); | |
| } | |
| } catch (error) { | |
| this.showAlert('Error uploading image: ' + error.message, 'error'); | |
| } | |
| // Reset file input | |
| document.getElementById('uploadFile').value = ''; | |
| } | |
| showAlert(message, type) { | |
| const alertsContainer = document.getElementById('alerts'); | |
| const alert = document.createElement('div'); | |
| alert.className = `alert alert-${type}`; | |
| alert.textContent = message; | |
| alertsContainer.appendChild(alert); | |
| // Auto-remove alert after 5 seconds | |
| setTimeout(() => { | |
| if (alert.parentNode) { | |
| alert.parentNode.removeChild(alert); | |
| } | |
| }, 5000); | |
| } | |
| } | |
| // Initialize the application when the page loads | |
| document.addEventListener('DOMContentLoaded', () => { | |
| new ComicAnnotator(); | |
| }); | |
| </script> | |
| </body> | |
| </html> |