Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>📸 Annotator</title> | |
| <!-- Xterm.js Files --> | |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css" /> | |
| <script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.min.js"></script> | |
| <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%; | |
| } | |
| .trainBtn { | |
| width: 100%; | |
| margin-top: 10px; | |
| } | |
| /* 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); | |
| } | |
| } | |
| .form-input-sm { | |
| padding: 4px 6px; | |
| font-size: 11px; | |
| height: auto; | |
| } | |
| .point-edit-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 4px; | |
| margin-bottom: 4px; | |
| padding: 4px; | |
| background: #f8fafc; | |
| border-radius: 4px; | |
| } | |
| .point-edit-input { | |
| flex: 1; | |
| padding: 2px 4px; | |
| font-size: 15px; | |
| border: 1px solid #d1d5db; | |
| border-radius: 3px; | |
| width: 50px; | |
| } | |
| .point-delete-btn { | |
| background: #f56565; | |
| color: white; | |
| border: none; | |
| border-radius: 3px; | |
| padding: 2px 6px; | |
| font-size: 15px; | |
| cursor: pointer; | |
| min-width: 20px; | |
| } | |
| .point-delete-btn:hover { | |
| background: #e53e3e; | |
| } | |
| /* Add to your existing CSS */ | |
| .point-highlighted { | |
| background: rgba(255, 215, 0, 0.8) ; | |
| border: 3px solid #ff6b00 ; | |
| box-shadow: 0 0 10px rgba(255, 107, 0, 0.6) ; | |
| } | |
| .point-edit-row.highlighted { | |
| background: #fff3cd ; | |
| border: 2px solid #ffc107 ; | |
| border-radius: 6px ; | |
| box-shadow: 0 2px 8px rgba(255, 193, 7, 0.3) ; | |
| } | |
| .point-edit-input.highlighted { | |
| border-color: #ffc107 ; | |
| background: #fff3cd ; | |
| } | |
| .modal { | |
| display: none; | |
| /* Hidden by default */ | |
| position: fixed; | |
| /* Stay in place */ | |
| z-index: 1000; | |
| /* Sit on top */ | |
| left: 0; | |
| top: 0; | |
| width: 100%; | |
| /* Full width */ | |
| height: 100%; | |
| /* Full height */ | |
| overflow: auto; | |
| /* Enable scroll if needed */ | |
| /* Black w/ opacity */ | |
| background-color: rgba(0, 0, 0, 0.8); | |
| } | |
| .modal-content { | |
| background-color: #fefefe; | |
| /* White background */ | |
| margin: 2% 0 0 28%; | |
| /* 15% from the top and centered */ | |
| padding: 20px; | |
| border: 1px solid #888; | |
| /* Border */ | |
| width: 80%; | |
| /* Could be more or less, depending on screen size */ | |
| max-width: 600px; | |
| /* Maximum width */ | |
| border-radius: 8px; | |
| /* Rounded corners */ | |
| box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); | |
| /* Shadow */ | |
| background: linear-gradient(135deg, #fff, #f0f0f0); | |
| border: 4px solid #000000; | |
| border-radius: 20px; | |
| box-shadow: 0 0 30px rgba(0, 0, 0, 0.3); | |
| padding: 30px; | |
| } | |
| #outputModal .modal-content { | |
| height: 85vh; | |
| background: black; | |
| overflow: hidden; | |
| /* Xterm handles its own scrollbar */ | |
| padding: 15px; | |
| box-sizing: border-box; | |
| } | |
| .all-button { | |
| position: fixed; | |
| top: 50px; | |
| right: 10%; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 10px; | |
| z-index: 1000; | |
| } | |
| .clear-button, | |
| .stop-button { | |
| float: right; | |
| font-size: 0.8em; | |
| width: auto; | |
| padding: 8px 12px; | |
| margin: 2px; | |
| margin-bottom: 10px; | |
| } | |
| .close { | |
| color: #FF6B6B; | |
| font-size: 40px; | |
| transition: all 0.3s ease; | |
| cursor: pointer; | |
| position: fixed; | |
| top: 22px; | |
| z-index: 10000; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Top Navigation --> | |
| <div class="top-nav"> | |
| <div class="logo"> | |
| 📸 Annotator | |
| </div> | |
| <div class="nav-actions"> | |
| <button class="btn btn-primary btn-sm" id="detectBtn" style="display: none;"> | |
| 🔄 Detect | |
| </button> | |
| <button class="btn btn-secondary btn-sm" id="downloadBtn" style="display: none;"> | |
| 📥 Download | |
| </button> | |
| <button class="btn btn-primary btn-sm" id="reloadBtn"> | |
| 🔄 Reload | |
| </button> | |
| <button class="btn btn-ghost btn-sm" id="prevBtn" disabled> | |
| ← Prev | |
| </button> | |
| <button class="btn btn-ghost btn-sm" id="nextBtn" disabled> | |
| Next → | |
| </button> | |
| <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" style="display: none;">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> --> | |
| <!-- Annotation Mode --> | |
| <div class="sidebar-section"> | |
| <div class="section-title">Annotation Mode</div> | |
| <div class="form-field"> | |
| <select class="form-select" id="annotationMode"> | |
| <option value="segmentation">Segmentation (YOLO-seg)</option> | |
| <option value="bbox">Bounding Box (YOLO)</option> | |
| </select> | |
| </div> | |
| <div class="form-field" style="display: none;"> | |
| <label class="form-label">Class ID</label> | |
| <input type="number" class="form-input" id="classId" value="0" min="0" max="100"> | |
| </div> | |
| </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">Annotations</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> | |
| <!-- Enhanced Edit Panel for Selected Annotation --> | |
| <div id="annotationEditPanel" style="display: none; margin-top: 16px;"> | |
| <div class="section-title">Edit Selected</div> | |
| <div class="info-card"> | |
| <div class="form-field" style="margin-bottom: 12px;"> | |
| <label class="form-label">Type</label> | |
| <span class="info-value" id="editType">-</span> | |
| </div> | |
| <div class="form-field" style="margin-bottom: 12px;"> | |
| <label class="form-label">Class ID</label> | |
| <input type="number" class="form-input form-input-sm" id="editClassId" min="0" max="100" | |
| style="padding: 6px 8px; font-size: 12px;"> | |
| </div> | |
| <!-- Bbox-specific controls --> | |
| <div id="bboxEditControls" style="display: none;"> | |
| <div class="form-field" style="margin-bottom: 8px;"> | |
| <label class="form-label">Position (X, Y)</label> | |
| <div style="display: flex; gap: 8px;"> | |
| <input type="number" class="form-input form-input-sm" id="editLeft" min="0" | |
| style="flex: 1; padding: 4px 6px; font-size: 11px;"> | |
| <input type="number" class="form-input form-input-sm" id="editTop" min="0" | |
| style="flex: 1; padding: 4px 6px; font-size: 11px;"> | |
| </div> | |
| </div> | |
| <div class="form-field" style="margin-bottom: 12px;"> | |
| <label class="form-label">Size (W × H)</label> | |
| <div style="display: flex; gap: 8px;"> | |
| <input type="number" class="form-input form-input-sm" id="editWidth" min="10" | |
| style="flex: 1; padding: 4px 6px; font-size: 11px;"> | |
| <input type="number" class="form-input form-input-sm" id="editHeight" min="10" | |
| style="flex: 1; padding: 4px 6px; font-size: 11px;"> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Polygon-specific controls --> | |
| <div id="polygonEditControls" style="display: none;"> | |
| <div class="form-field" style="margin-bottom: 8px;"> | |
| <label class="form-label">Points (<span id="pointCount">0</span>)</label> | |
| <div id="pointsList" style="max-height: 400px; overflow-y: auto; | |
| border: 1px solid #e2e8f0; border-radius: 4px; padding: 8px;"> | |
| <!-- Points will be populated here --> | |
| </div> | |
| </div> | |
| </div> | |
| <div style="display: flex; gap: 8px; margin-top: 12px;"> | |
| <button class="btn btn-success btn-sm" id="applyEditBtn" | |
| style="flex: 1; padding: 6px 8px; font-size: 11px;"> | |
| ✓ Apply | |
| </button> | |
| <button class="btn btn-secondary btn-sm" id="cancelEditBtn" | |
| style="flex: 1; padding: 6px 8px; font-size: 11px;"> | |
| ✗ Cancel | |
| </button> | |
| <button class="btn btn-danger btn-sm" id="deleteEditBtn" | |
| style="padding: 6px 8px; font-size: 11px;"> | |
| 🗑️ | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Quick Actions --> | |
| <div class="sidebar-section"> | |
| <div class="section-title">Actions</div> | |
| <div style="display: flex; gap: 8px;"> | |
| <button class="btn btn-primary btn-sm trainBtn" id="trainBtn" style="flex: 1;"> | |
| Train | |
| </button> | |
| <button class="btn btn-ghost btn-sm" id="settingsBtn" style="padding: 6px 8px; font-size: 11px;"> | |
| ⚙️ | |
| </button> | |
| </div> | |
| <button class="btn btn-primary btn-sm trainBtn" id="deployModalBtn"> | |
| Deploy Model | |
| </button> | |
| <button class="btn btn-primary btn-sm trainBtn" id="resetModalBtn"> | |
| Reset Model | |
| </button> | |
| <!-- <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 annotations • Select annotations to move or resize | |
| </span> --> | |
| <div class="form-field"> | |
| <select class="form-select" id="imageSelect"> | |
| <option value="">Choose an image...</option> | |
| </select> | |
| </div> | |
| <span id="annotatedImages">0</span>/<span id="totalImages">0</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> | |
| <!-- Settings Modal --> | |
| <div id="settingsModal" class="modal"> | |
| <div class="modal-content"> | |
| <span class="close" id="closeSettingsModal">×</span> | |
| <h2>Train Settings</h2> | |
| <div class="form-field"> | |
| <label class="form-label">Epoch</label> | |
| <input type="number" class="form-input" id="epoch" value="10" min="1"> | |
| </div> | |
| <div class="form-field"> | |
| <label class="form-label">Batch Size</label> | |
| <input type="number" class="form-input" id="batch" value="8" min="1"> | |
| </div> | |
| <div class="form-field"> | |
| <label class="form-label">Image Size</label> | |
| <input type="number" class="form-input" id="imgsz" value="640" min="1"> | |
| </div> | |
| <div class="form-field"> | |
| <label class="form-label"> | |
| <input type="checkbox" id="recreateDataset" checked> | |
| Recreate Dataset | |
| </label> | |
| </div> | |
| <div class="form-field"> | |
| <label class="form-label"> | |
| <input type="checkbox" id="resumeTrain" checked> | |
| Resume Train | |
| </label> | |
| </div> | |
| <button class="btn btn-primary" id="saveSettingsBtn">Save</button> | |
| </div> | |
| </div> | |
| <!-- Deploy Modal --> | |
| <div id="deployModal" class="modal"> | |
| <div class="modal-content"> | |
| <span class="close" id="closeDeployModal">×</span> | |
| <h2>Deploy Model</h2> | |
| <div class="form-field"> | |
| <label class="form-label">App Name</label> | |
| <input type="text" class="form-input" id="appName" placeholder="Enter a unique app name"> | |
| </div> | |
| <button class="btn btn-primary" id="deployBtn">Deploy</button> | |
| </div> | |
| </div> | |
| <div id="outputModal" class="modal"> | |
| <div class="modal-content" style="max-width: none; margin: auto;"> | |
| <span class="close" id="closeModal">×</span> | |
| <div class="all-button"> | |
| <!-- REMOVED: Scroll control button --> | |
| <button class="stop-button" id="stopTrain">Stop</button> | |
| <button class="clear-button" id="clearOutput">Clear</button> | |
| </div> | |
| <!-- This div will host the xterm.js terminal --> | |
| <div id="output"></div> | |
| </div> | |
| </div> | |
| <script> | |
| // NEW: Xterm.js variables | |
| let term; | |
| let fitAddon; | |
| function getWebSocketURL() { | |
| const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; | |
| if (isLocal) { | |
| return 'ws://localhost:' + window.location.port + '/ws'; | |
| } else { | |
| // Use current domain for Spaces | |
| const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| return `${protocol}//${window.location.host}/ws`; | |
| } | |
| } | |
| const socket = new WebSocket(getWebSocketURL()); | |
| socket.onmessage = function (event) { | |
| if (!term) return; | |
| const data = JSON.parse(event.data); | |
| term.write(data.data + "\n"); | |
| }; | |
| class ComicAnnotator { | |
| constructor() { | |
| this.canvas = document.getElementById('annotationCanvas'); | |
| this.ctx = this.canvas.getContext('2d'); | |
| this.annotations = []; | |
| 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; | |
| // Segmentation state | |
| this.annotationMode = 'segmentation'; // 'bbox' or 'segmentation' | |
| this.currentPolygon = null; | |
| this.isDrawingPolygon = false; | |
| this.polygonPoints = []; | |
| // 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; | |
| // Add these new properties for edge resizing | |
| this.isDraggingEdge = false; | |
| this.draggingEdgeIndex = -1; | |
| this.edgeResizeTolerance = 8; | |
| this.showEdgeHandles = true; | |
| // Dynamic annotation detection | |
| this.waitingForDrag = false; | |
| this.initialClickPos = null; | |
| this.clickStartTime = 0; | |
| this.modeIndicatorTimeout = null; | |
| // Add this new property for point highlighting | |
| this.highlightedPointIndex = -1; | |
| this.init(); | |
| } | |
| init() { | |
| this.setupEventListeners(); | |
| this.loadImages(); | |
| this.loadTrainConfig(); | |
| } | |
| setupEventListeners() { | |
| // Mode selection | |
| document.getElementById('annotationMode').addEventListener('change', (e) => { | |
| this.annotationMode = e.target.value; | |
| this.selectedBoxIndex = -1; | |
| this.polygonPoints = []; | |
| this.isDrawingPolygon = false; | |
| this.updateCanvasCursor(); | |
| this.drawCanvas(); | |
| }); | |
| // 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.clearAllAnnotations()); | |
| 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()); | |
| this.canvas.addEventListener('dblclick', (e) => this.onDoubleClick(e)); | |
| // Keyboard events | |
| document.addEventListener('keydown', (e) => this.onKeyDown(e)); | |
| // Edit panel buttons | |
| document.getElementById('applyEditBtn').addEventListener('click', () => this.applyEdits()); | |
| document.getElementById('cancelEditBtn').addEventListener('click', () => this.cancelEdits()); | |
| document.getElementById('deleteEditBtn').addEventListener('click', () => this.deleteSelectedBox()); | |
| // Real-time bbox updates | |
| document.getElementById('editLeft').addEventListener('input', () => this.updateBboxFromInputs()); | |
| document.getElementById('editTop').addEventListener('input', () => this.updateBboxFromInputs()); | |
| document.getElementById('editWidth').addEventListener('input', () => this.updateBboxFromInputs()); | |
| document.getElementById('editHeight').addEventListener('input', () => this.updateBboxFromInputs()); | |
| // Class ID real-time update | |
| document.getElementById('editClassId').addEventListener('input', (e) => { | |
| if (this.selectedBoxIndex >= 0) { | |
| this.annotations[this.selectedBoxIndex].classId = parseInt(e.target.value) || 0; | |
| this.annotations[this.selectedBoxIndex].saved = false; | |
| } | |
| }); | |
| // Make canvas focusable for keyboard events | |
| this.canvas.tabIndex = 0; | |
| document.getElementById('trainBtn').addEventListener('click', async (e) => { | |
| try { | |
| this.openXterm(); | |
| const response = await fetch('/api/annotate/train'); | |
| if (!response.ok) { | |
| throw new Error(`Server error: ${response.status}`); | |
| } | |
| const result = await response.json(); | |
| this.showAlert(result.message, 'success'); | |
| } catch (error) { | |
| if (term) { | |
| term.write(`\x1b[31m[Error starting command: ${error.message}]\x1b[0m\r\n`); | |
| } else { | |
| this.showAlert('Error starting command: ' + error.message, 'error'); | |
| } | |
| } | |
| // Reset file input | |
| document.getElementById('uploadFile').value = ''; | |
| }); | |
| document.getElementById('stopTrain').addEventListener('click', async (e) => { | |
| this.stopTrain() | |
| }); | |
| document.getElementById('clearOutput').addEventListener('click', async (e) => { | |
| this.clearOutput() | |
| }); | |
| document.getElementById('closeModal').addEventListener('click', async (e) => { | |
| this.closeTrainModal() | |
| }); | |
| // NEW: Add resize listener to refit terminal on window resize | |
| window.addEventListener('resize', () => { | |
| if (document.getElementById('outputModal').style.display === 'block' && fitAddon) { | |
| try { | |
| fitAddon.fit(); | |
| } catch (e) { | |
| console.error("Error fitting terminal on resize:", e); | |
| } | |
| } | |
| }); | |
| // Settings Modal | |
| document.getElementById('settingsBtn').addEventListener('click', () => { | |
| document.getElementById('settingsModal').style.display = 'block'; | |
| }); | |
| document.getElementById('closeSettingsModal').addEventListener('click', () => { | |
| document.getElementById('settingsModal').style.display = 'none'; | |
| }); | |
| document.getElementById('saveSettingsBtn').addEventListener('click', async () => { | |
| const newSettings = { | |
| epoch: parseInt(document.getElementById('epoch').value) || 200, | |
| batch: parseInt(document.getElementById('batch').value) || 10, | |
| imgsz: parseInt(document.getElementById('imgsz').value) || 640, | |
| recreate_dataset: document.getElementById('recreateDataset').checked, | |
| resume_train: document.getElementById('resumeTrain').checked | |
| }; | |
| try { | |
| const response = await fetch('/api/annotate/train/config', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify(newSettings) | |
| }); | |
| if (!response.ok) { | |
| const errorData = await response.json(); | |
| throw new Error(errorData.message || 'Failed to save settings'); | |
| } | |
| const result = await response.json(); | |
| this.trainSettings = newSettings; | |
| document.getElementById('settingsModal').style.display = 'none'; | |
| this.showAlert(result.message || 'Settings saved successfully', 'success'); | |
| } catch (error) { | |
| console.error('Error saving settings:', error); | |
| this.showAlert('Error: ' + error.message, 'error'); | |
| } | |
| }); | |
| // Deploy Modal | |
| document.getElementById('deployModalBtn').addEventListener('click', () => { | |
| document.getElementById('deployModal').style.display = 'block'; | |
| }); | |
| document.getElementById('closeDeployModal').addEventListener('click', () => { | |
| document.getElementById('deployModal').style.display = 'none'; | |
| }); | |
| document.getElementById('deployBtn').addEventListener('click', async () => { | |
| var appName = document.getElementById('appName').value; | |
| if (!appName) { | |
| appName = "Comic Panel Extractor" | |
| this.showAlert('Using App Name: "Comic Panel Extractor"'); | |
| } | |
| try { | |
| const response = await fetch(`/api/annotate/deploy?app_name=${appName}`); | |
| if (!response.ok) { | |
| throw new Error(`Server error: ${response.status}`); | |
| } | |
| const result = await response.json(); | |
| this.showAlert(result.message, 'success'); | |
| } catch (error) { | |
| if (term) { | |
| term.write(`\x1b[31m[Error starting command: ${error.message}]\x1b[0m\r\n`); | |
| } else { | |
| this.showAlert('Error starting command: ' + error.message, 'error'); | |
| } | |
| } | |
| document.getElementById('deployModal').style.display = 'none'; | |
| }); | |
| // Reset Model | |
| document.getElementById('resetModalBtn').addEventListener('click', async () => { | |
| if (confirm('Are you sure you want to reset the model? This action cannot be undone.')) { | |
| try { | |
| const response = await fetch('/api/annotate/model_reset', { method: 'POST' }); | |
| if (!response.ok) { | |
| throw new Error(`Server error: ${response.status}`); | |
| } | |
| const result = await response.json(); | |
| this.showAlert(result.message, 'success'); | |
| } catch (error) { | |
| if (term) { | |
| term.write(`\x1b[31m[Error starting command: ${error.message}]\x1b[0m\r\n`); | |
| } else { | |
| this.showAlert('Error starting command: ' + error.message, 'error'); | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| async loadTrainConfig() { | |
| try { | |
| const response = await fetch('/api/annotate/train/config'); | |
| const config = await response.json(); | |
| this.trainSettings = config; | |
| document.getElementById('epoch').value = config.epoch; | |
| document.getElementById('batch').value = config.batch; | |
| document.getElementById('imgsz').value = config.imgsz; | |
| document.getElementById('recreateDataset').checked = config.recreate_dataset; | |
| document.getElementById('resumeTrain').checked = config.resume_train; | |
| } catch (error) { | |
| this.showAlert('Error loading training config: ' + error.message, 'error'); | |
| } | |
| } | |
| updateCanvasCursor() { | |
| if (this.annotationMode === 'segmentation') { | |
| this.canvas.style.cursor = 'crosshair'; | |
| } else { | |
| this.canvas.style.cursor = 'crosshair'; | |
| } | |
| } | |
| 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); | |
| } | |
| } | |
| resetAnnotationStates() { | |
| // Reset selection | |
| this.selectedBoxIndex = -1; | |
| // Reset drawing states | |
| this.isDrawing = false; | |
| this.isDragging = false; | |
| this.isResizing = false; | |
| this.isDraggingEdge = false; | |
| this.isDraggingPoint = false; | |
| // Reset segmentation states | |
| this.isDrawingPolygon = false; | |
| this.polygonPoints = []; | |
| this.currentPolygon = null; | |
| // Reset bbox states | |
| this.currentBox = null; | |
| this.resizeHandle = null; | |
| // Reset drag states | |
| this.draggingEdgeIndex = -1; | |
| this.draggingPointIndex = -1; | |
| this.dragStartX = 0; | |
| this.dragStartY = 0; | |
| this.lastMouseX = 0; | |
| this.lastMouseY = 0; | |
| // Reset dynamic detection states | |
| this.waitingForDrag = false; | |
| this.initialClickPos = null; | |
| this.clickStartTime = 0; | |
| this.highlightedPointIndex = -1; | |
| // Reset cursor | |
| this.canvas.style.cursor = 'crosshair'; | |
| // Hide edit panel | |
| const editPanel = document.getElementById('annotationEditPanel'); | |
| if (editPanel) { | |
| editPanel.style.display = 'none'; | |
| } | |
| } | |
| async loadImage(imageName) { | |
| try { | |
| this.resetAnnotationStates(); | |
| // 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.annotations = annotationsData.annotations || []; | |
| 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.annotations.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); | |
| if (this.backgroundImage) { | |
| this.ctx.drawImage(this.backgroundImage, 0, 0); | |
| } | |
| // Draw existing annotations | |
| this.annotations.forEach((annotation, 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)'; | |
| this.setAnnotationMode(annotation.type); | |
| } else if (!annotation.saved) { | |
| strokeColor = '#ff0000'; | |
| fillColor = 'rgba(255, 0, 0, 0.2)'; | |
| } | |
| if (annotation.type === 'segmentation') { | |
| this.drawPolygon(annotation.points, strokeColor, fillColor, index === this.selectedBoxIndex); | |
| } else { | |
| this.drawBox(annotation.left, annotation.top, annotation.width, annotation.height, strokeColor, fillColor); | |
| } | |
| // Draw resize handles for selected bbox | |
| if (index === this.selectedBoxIndex && annotation.type === 'bbox') { | |
| this.drawResizeHandles(annotation); | |
| } | |
| }); | |
| // 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)'); | |
| } | |
| // Draw current polygon being drawn | |
| if (this.currentPolygon && this.currentPolygon.points.length > 0) { | |
| this.drawPolygon(this.currentPolygon.points, '#ff0000', 'rgba(255, 0, 0, 0.3)'); | |
| } | |
| } | |
| drawPolygon(points, strokeColor, fillColor, isSelected = false) { | |
| if (points.length < 2) return; | |
| this.ctx.strokeStyle = strokeColor; | |
| this.ctx.fillStyle = fillColor; | |
| this.ctx.lineWidth = 3; | |
| this.ctx.beginPath(); | |
| this.ctx.moveTo(points[0].x, points[0].y); | |
| for (let i = 1; i < points.length; i++) { | |
| this.ctx.lineTo(points[i].x, points[i].y); | |
| } | |
| if (points.length > 2) { | |
| this.ctx.closePath(); | |
| this.ctx.fill(); | |
| } | |
| this.ctx.stroke(); | |
| // Draw corner points as squares | |
| const handleSize = isSelected ? 10 : 6; | |
| points.forEach((point, index) => { | |
| // Check if this point should be highlighted | |
| const isHighlighted = isSelected && this.highlightedPointIndex === index; | |
| if (isHighlighted) { | |
| // Draw highlighted point with special styling | |
| this.ctx.fillStyle = '#FFD700'; // Gold color | |
| this.ctx.strokeStyle = '#FF6B00'; // Orange border | |
| this.ctx.lineWidth = 3; | |
| // Draw larger, more visible handle | |
| const highlightSize = handleSize + 6; | |
| this.ctx.fillRect( | |
| point.x - highlightSize / 2, | |
| point.y - highlightSize / 2, | |
| highlightSize, | |
| highlightSize | |
| ); | |
| this.ctx.strokeRect( | |
| point.x - highlightSize / 2, | |
| point.y - highlightSize / 2, | |
| highlightSize, | |
| highlightSize | |
| ); | |
| // Add pulsing glow effect | |
| this.ctx.shadowColor = '#FF6B00'; | |
| this.ctx.shadowBlur = 10; | |
| this.ctx.strokeRect( | |
| point.x - highlightSize / 2, | |
| point.y - highlightSize / 2, | |
| highlightSize, | |
| highlightSize | |
| ); | |
| this.ctx.shadowBlur = 0; // Reset shadow | |
| } else { | |
| // Draw normal point | |
| this.ctx.fillStyle = isSelected ? '#0066ff' : strokeColor; | |
| this.ctx.strokeStyle = '#ffffff'; | |
| this.ctx.lineWidth = 2; | |
| this.ctx.fillRect( | |
| point.x - handleSize / 2, | |
| point.y - handleSize / 2, | |
| handleSize, | |
| handleSize | |
| ); | |
| if (isSelected) { | |
| this.ctx.strokeRect( | |
| point.x - handleSize / 2, | |
| point.y - handleSize / 2, | |
| handleSize, | |
| handleSize | |
| ); | |
| } | |
| } | |
| }); | |
| // Draw edge handles for selected polygon (existing code) | |
| if (isSelected && points.length > 2 && this.showEdgeHandles) { | |
| this.drawEdgeHandles(points); | |
| } | |
| } | |
| drawEdgeHandles(points) { | |
| this.ctx.fillStyle = 'rgba(0, 102, 255, 0.8)'; | |
| this.ctx.strokeStyle = '#ffffff'; | |
| this.ctx.lineWidth = 2; | |
| for (let i = 0; i < points.length; i++) { | |
| const nextIndex = (i + 1) % points.length; | |
| const midX = (points[i].x + points[nextIndex].x) / 2; | |
| const midY = (points[i].y + points[nextIndex].y) / 2; | |
| // Draw square handle | |
| const handleSize = 10; | |
| this.ctx.fillRect(midX - handleSize / 2, midY - handleSize / 2, handleSize, handleSize); | |
| this.ctx.strokeRect(midX - handleSize / 2, midY - handleSize / 2, handleSize, handleSize); | |
| } | |
| } | |
| // NEW METHOD: Get edge handle at position | |
| getEdgeHandleAtPosition(x, y, tolerance = 8) { | |
| if (this.selectedBoxIndex >= 0 && this.annotations[this.selectedBoxIndex].type === 'segmentation') { | |
| const points = this.annotations[this.selectedBoxIndex].points; | |
| for (let i = 0; i < points.length; i++) { | |
| const nextIndex = (i + 1) % points.length; | |
| const midX = (points[i].x + points[nextIndex].x) / 2; | |
| const midY = (points[i].y + points[nextIndex].y) / 2; | |
| const distance = Math.sqrt(Math.pow(x - midX, 2) + Math.pow(y - midY, 2)); | |
| if (distance <= tolerance) { | |
| return i; | |
| } | |
| } | |
| } | |
| return -1; | |
| } | |
| // NEW METHOD: Resize polygon edge | |
| resizePolygonEdge(annotationIndex, edgeIndex, mouseX, mouseY) { | |
| if (annotationIndex < 0 || annotationIndex >= this.annotations.length) return; | |
| const annotation = this.annotations[annotationIndex]; | |
| if (annotation.type !== 'segmentation') return; | |
| const points = annotation.points; | |
| const nextIndex = (edgeIndex + 1) % points.length; | |
| // Get the edge vector | |
| const edgeVectorX = points[nextIndex].x - points[edgeIndex].x; | |
| const edgeVectorY = points[nextIndex].y - points[edgeIndex].y; | |
| // Get the edge normal (perpendicular vector) | |
| const edgeLength = Math.sqrt(edgeVectorX * edgeVectorX + edgeVectorY * edgeVectorY); | |
| if (edgeLength === 0) return; | |
| const normalX = -edgeVectorY / edgeLength; | |
| const normalY = edgeVectorX / edgeLength; | |
| // Get edge midpoint | |
| const edgeMidX = (points[edgeIndex].x + points[nextIndex].x) / 2; | |
| const edgeMidY = (points[edgeIndex].y + points[nextIndex].y) / 2; | |
| // Calculate how far to move the edge | |
| const mouseVectorX = mouseX - edgeMidX; | |
| const mouseVectorY = mouseY - edgeMidY; | |
| // Project mouse movement onto the normal | |
| const moveDistance = mouseVectorX * normalX + mouseVectorY * normalY; | |
| // Move both edge points | |
| const newX1 = Math.max(0, Math.min(this.originalWidth, points[edgeIndex].x + normalX * moveDistance)); | |
| const newY1 = Math.max(0, Math.min(this.originalHeight, points[edgeIndex].y + normalY * moveDistance)); | |
| const newX2 = Math.max(0, Math.min(this.originalWidth, points[nextIndex].x + normalX * moveDistance)); | |
| const newY2 = Math.max(0, Math.min(this.originalHeight, points[nextIndex].y + normalY * moveDistance)); | |
| points[edgeIndex].x = newX1; | |
| points[edgeIndex].y = newY1; | |
| points[nextIndex].x = newX2; | |
| points[nextIndex].y = newY2; | |
| annotation.saved = false; | |
| } | |
| // Polygon-specific editing methods | |
| getPolygonAtPosition(x, y) { | |
| for (let i = this.annotations.length - 1; i >= 0; i--) { | |
| const annotation = this.annotations[i]; | |
| if (annotation.type === 'segmentation' && this.isPointInPolygon(x, y, annotation.points)) { | |
| return i; | |
| } | |
| } | |
| return -1; | |
| } | |
| isPointInPolygon(x, y, points) { | |
| let inside = false; | |
| for (let i = 0, j = points.length - 1; i < points.length; j = i++) { | |
| if (((points[i].y > y) !== (points[j].y > y)) && | |
| (x < (points[j].x - points[i].x) * (y - points[i].y) / (points[j].y - points[i].y) + points[i].x)) { | |
| inside = !inside; | |
| } | |
| } | |
| return inside; | |
| } | |
| getPolygonPointAtPosition(x, y, tolerance = 8) { | |
| if (this.selectedBoxIndex >= 0 && this.annotations[this.selectedBoxIndex].type === 'segmentation') { | |
| const points = this.annotations[this.selectedBoxIndex].points; | |
| for (let i = 0; i < points.length; i++) { | |
| const distance = Math.sqrt(Math.pow(x - points[i].x, 2) + Math.pow(y - points[i].y, 2)); | |
| if (distance <= tolerance) { | |
| return i; | |
| } | |
| } | |
| } | |
| return -1; | |
| } | |
| getPolygonEdgeAtPosition(x, y, tolerance = 5) { | |
| if (this.selectedBoxIndex >= 0 && this.annotations[this.selectedBoxIndex].type === 'segmentation') { | |
| const points = this.annotations[this.selectedBoxIndex].points; | |
| for (let i = 0; i < points.length; i++) { | |
| const nextIndex = (i + 1) % points.length; | |
| const p1 = points[i]; | |
| const p2 = points[nextIndex]; | |
| const distance = this.distanceToLineSegment(x, y, p1.x, p1.y, p2.x, p2.y); | |
| if (distance <= tolerance) { | |
| return i; // Return the index of the first point of the edge | |
| } | |
| } | |
| } | |
| return -1; | |
| } | |
| distanceToLineSegment(px, py, x1, y1, x2, y2) { | |
| const dx = x2 - x1; | |
| const dy = y2 - y1; | |
| const length = Math.sqrt(dx * dx + dy * dy); | |
| if (length === 0) return Math.sqrt((px - x1) ** 2 + (py - y1) ** 2); | |
| const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / (length * length))); | |
| const projectionX = x1 + t * dx; | |
| const projectionY = y1 + t * dy; | |
| return Math.sqrt((px - projectionX) ** 2 + (py - projectionY) ** 2); | |
| } | |
| movePolygon(index, deltaX, deltaY) { | |
| if (index >= 0 && index < this.annotations.length && this.annotations[index].type === 'segmentation') { | |
| const annotation = this.annotations[index]; | |
| annotation.points = annotation.points.map(point => ({ | |
| x: Math.max(0, Math.min(this.originalWidth, point.x + deltaX)), | |
| y: Math.max(0, Math.min(this.originalHeight, point.y + deltaY)) | |
| })); | |
| annotation.saved = false; | |
| } | |
| } | |
| movePolygonPoint(annotationIndex, pointIndex, newX, newY) { | |
| if (annotationIndex >= 0 && annotationIndex < this.annotations.length) { | |
| const annotation = this.annotations[annotationIndex]; | |
| if (annotation.type === 'segmentation' && pointIndex >= 0 && pointIndex < annotation.points.length) { | |
| annotation.points[pointIndex].x = Math.max(0, Math.min(this.originalWidth, newX)); | |
| annotation.points[pointIndex].y = Math.max(0, Math.min(this.originalHeight, newY)); | |
| annotation.saved = false; | |
| } | |
| } | |
| } | |
| addPolygonPoint(annotationIndex, edgeIndex, x, y) { | |
| if (annotationIndex >= 0 && annotationIndex < this.annotations.length) { | |
| const annotation = this.annotations[annotationIndex]; | |
| if (annotation.type === 'segmentation') { | |
| const insertIndex = edgeIndex + 1; | |
| annotation.points.splice(insertIndex, 0, { x, y }); | |
| annotation.saved = false; | |
| } | |
| } | |
| } | |
| removePolygonPoint(annotationIndex, pointIndex) { | |
| if (annotationIndex >= 0 && annotationIndex < this.annotations.length) { | |
| const annotation = this.annotations[annotationIndex]; | |
| if (annotation.type === 'segmentation' && annotation.points.length > 3) { | |
| annotation.points.splice(pointIndex, 1); | |
| annotation.saved = false; | |
| } | |
| } | |
| } | |
| 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; | |
| const handle = this.getResizeHandle(pos.x, pos.y); | |
| // Check for existing annotation interactions first (both types) | |
| if (this.selectedBoxIndex >= 0) { | |
| const annotation = this.annotations[this.selectedBoxIndex]; | |
| if (annotation.type === 'segmentation') { | |
| // Handle segmentation interactions | |
| const edgeHandleIndex = this.getEdgeHandleAtPosition(pos.x, pos.y); | |
| if (edgeHandleIndex >= 0) { | |
| this.isDraggingEdge = true; | |
| this.draggingEdgeIndex = edgeHandleIndex; | |
| this.canvas.style.cursor = handle.cursor; | |
| return; | |
| } | |
| const pointIndex = this.getPolygonPointAtPosition(pos.x, pos.y); | |
| if (pointIndex >= 0) { | |
| this.isDraggingPoint = true; | |
| this.draggingPointIndex = pointIndex; | |
| this.canvas.style.cursor = handle.cursor; | |
| return; | |
| } | |
| const edgeIndex = this.getPolygonEdgeAtPosition(pos.x, pos.y); | |
| if (edgeIndex >= 0 && e.ctrlKey) { | |
| this.addPolygonPoint(this.selectedBoxIndex, edgeIndex, pos.x, pos.y); | |
| this.drawCanvas(); | |
| return; | |
| } | |
| } else if (annotation.type === 'bbox') { | |
| // If clicking on a resize handle | |
| if (handle && handle.type !== 'default' && handle.type !== 'interior') { | |
| this.isResizing = true; | |
| this.resizeHandle = handle; | |
| this.canvas.style.cursor = handle.cursor; | |
| return; | |
| } | |
| // If clicking inside bbox (for moving) | |
| if (handle && handle.type === 'interior') { | |
| this.isDragging = true; | |
| this.dragStartX = pos.x; | |
| this.dragStartY = pos.y; | |
| this.canvas.style.cursor = 'move'; | |
| this.updateSelectedBoxInfo(); | |
| return; | |
| } | |
| } | |
| } | |
| // IMPORTANT: Check if already drawing a polygon - add point to current polygon | |
| if (this.annotationMode === 'segmentation' && this.isDrawingPolygon) { | |
| this.polygonPoints.push(pos); | |
| this.currentPolygon.points = [...this.polygonPoints]; | |
| this.drawCanvas(); | |
| return; | |
| } | |
| // Check for clicking on existing annotations | |
| const clickedPolygonIndex = this.getPolygonAtPosition(pos.x, pos.y); | |
| const clickedBoxIndex = this.getBoxAtPosition(pos.x, pos.y); | |
| if (clickedPolygonIndex >= 0) { | |
| this.selectedBoxIndex = clickedPolygonIndex; | |
| this.isDragging = true; | |
| this.dragStartX = pos.x; | |
| this.dragStartY = pos.y; | |
| this.canvas.style.cursor = handle.cursor; | |
| this.updateSelectedBoxInfo(); | |
| this.drawCanvas(); | |
| return; | |
| } | |
| if (clickedBoxIndex >= 0) { | |
| this.selectedBoxIndex = clickedBoxIndex; | |
| this.isDragging = true; | |
| this.dragStartX = pos.x; | |
| this.dragStartY = pos.y; | |
| this.canvas.style.cursor = handle.cursor; | |
| this.updateSelectedBoxInfo(); | |
| this.drawCanvas(); | |
| return; | |
| } | |
| // No existing annotation clicked - determine new annotation type | |
| this.selectedBoxIndex = -1; | |
| this.updateSelectedBoxInfo(); | |
| // DYNAMIC ANNOTATION TYPE DETECTION (only when not already drawing) | |
| if (e.ctrlKey) { | |
| // Ctrl+Click always starts segmentation | |
| this.startSegmentation(pos); | |
| } else if (e.shiftKey) { | |
| // Shift+Click always starts bbox | |
| this.startBboxDrawing(pos); | |
| } else { | |
| // Default behavior: start with a click and wait for drag | |
| this.waitingForDrag = true; | |
| this.initialClickPos = pos; | |
| this.clickStartTime = Date.now(); | |
| } | |
| } | |
| startSegmentation(pos) { | |
| this.setAnnotationMode('segmentation'); | |
| this.isDrawingPolygon = true; | |
| this.polygonPoints = [pos]; | |
| this.currentPolygon = { | |
| points: [...this.polygonPoints], | |
| classId: parseInt(document.getElementById('classId').value) || 0, | |
| saved: false | |
| }; | |
| this.drawCanvas(); | |
| this.showModeIndicator('Segmentation Mode, After pointing three/more points press enter to release', 'segmentation'); | |
| } | |
| startBboxDrawing(pos) { | |
| this.setAnnotationMode('bbox'); | |
| this.startX = pos.x; | |
| this.startY = pos.y; | |
| this.isDrawing = true; | |
| this.currentBox = { left: this.startX, top: this.startY, width: 0, height: 0 }; | |
| this.showModeIndicator('Bounding Box Mode', 'bbox'); | |
| } | |
| setAnnotationMode(mode) { | |
| this.annotationMode = mode; | |
| document.getElementById('annotationMode').value = mode; | |
| this.updateCanvasCursor(); | |
| } | |
| showModeIndicator(text, type, persistent = false) { | |
| // Create or update mode indicator | |
| let indicator = document.getElementById('modeIndicator'); | |
| if (!indicator) { | |
| indicator = document.createElement('div'); | |
| indicator.id = 'modeIndicator'; | |
| indicator.style.cssText = ` | |
| position: fixed; | |
| top: 120px; | |
| right: 20px; | |
| z-index: 1001; | |
| padding: 8px 16px; | |
| border-radius: 6px; | |
| font-size: 13px; | |
| font-weight: 500; | |
| pointer-events: none; | |
| transition: opacity 0.3s ease; | |
| `; | |
| document.body.appendChild(indicator); | |
| } | |
| const colors = { | |
| 'segmentation': { bg: '#e6f3ff', color: '#0066cc', border: '#4299e1' }, | |
| 'bbox': { bg: '#f0fff4', color: '#22543d', border: '#48bb78' } | |
| }; | |
| const color = colors[type] || colors.bbox; | |
| indicator.style.background = color.bg; | |
| indicator.style.color = color.color; | |
| indicator.style.border = `1px solid ${color.border}`; | |
| indicator.textContent = text; | |
| indicator.style.opacity = '1'; | |
| // Auto-hide only if not persistent | |
| if (!persistent) { | |
| clearTimeout(this.modeIndicatorTimeout); | |
| this.modeIndicatorTimeout = setTimeout(() => { | |
| indicator.style.opacity = '0'; | |
| }, 2000); | |
| } | |
| } | |
| onDoubleClick(e) { | |
| if (this.annotationMode === 'segmentation' && this.isDrawingPolygon && this.polygonPoints.length >= 3) { | |
| // Finish polygon | |
| this.finishPolygon(); | |
| } | |
| } | |
| finishPolygon() { | |
| if (this.polygonPoints.length >= 3) { | |
| const annotation = { | |
| type: 'segmentation', | |
| points: [...this.polygonPoints], | |
| classId: parseInt(document.getElementById('classId').value) || 0, | |
| saved: false | |
| }; | |
| this.annotations.push(annotation); | |
| this.selectedBoxIndex = this.annotations.length - 1; | |
| document.getElementById('boxCount').textContent = this.annotations.length; | |
| this.updateSelectedBoxInfo(); | |
| } | |
| this.isDrawingPolygon = false; | |
| this.polygonPoints = []; | |
| this.currentPolygon = null; | |
| this.drawCanvas(); | |
| } | |
| onMouseMove(e) { | |
| if (!this.currentImage) return; | |
| const pos = this.getMousePos(e); | |
| // Handle dynamic annotation type detection | |
| if (this.waitingForDrag && this.initialClickPos) { | |
| const dragDistance = Math.sqrt( | |
| Math.pow(pos.x - this.initialClickPos.x, 2) + | |
| Math.pow(pos.y - this.initialClickPos.y, 2) | |
| ); | |
| const timeSinceClick = Date.now() - this.clickStartTime; | |
| // If dragging more than 10 pixels, assume bbox | |
| if (dragDistance > 10) { | |
| this.waitingForDrag = false; | |
| this.startBboxDrawing(this.initialClickPos); | |
| // Continue with bbox drawing | |
| this.currentBox.width = pos.x - this.startX; | |
| this.currentBox.height = pos.y - this.startY; | |
| this.drawCanvas(); | |
| return; | |
| } | |
| // If holding click for more than 500ms without drag, assume segmentation | |
| if (timeSinceClick > 500) { | |
| this.waitingForDrag = false; | |
| this.startSegmentation(this.initialClickPos); | |
| return; | |
| } | |
| return; // Still waiting to determine type | |
| } | |
| // Rest of your existing mouse move logic... | |
| if (this.annotationMode === 'segmentation') { | |
| if (this.isDraggingEdge && this.selectedBoxIndex >= 0) { | |
| this.resizePolygonEdge(this.selectedBoxIndex, this.draggingEdgeIndex, pos.x, pos.y); | |
| this.drawCanvas(); | |
| return; | |
| } | |
| if (this.isDraggingPoint && this.selectedBoxIndex >= 0) { | |
| this.movePolygonPoint(this.selectedBoxIndex, this.draggingPointIndex, pos.x, pos.y); | |
| this.drawCanvas(); | |
| return; | |
| } | |
| if (this.isDragging && this.selectedBoxIndex >= 0) { | |
| const deltaX = pos.x - this.lastMouseX; | |
| const deltaY = pos.y - this.lastMouseY; | |
| this.movePolygon(this.selectedBoxIndex, deltaX, deltaY); | |
| this.lastMouseX = pos.x; | |
| this.lastMouseY = pos.y; | |
| this.drawCanvas(); | |
| return; | |
| } | |
| if (this.isDrawingPolygon) { | |
| if (this.currentPolygon) { | |
| this.currentPolygon.points = [...this.polygonPoints, pos]; | |
| this.drawCanvas(); | |
| } | |
| return; | |
| } | |
| this.updatePolygonCursor(pos.x, pos.y); | |
| return; | |
| } | |
| // Original bbox mouse move logic... | |
| 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 { | |
| this.updateCursor(pos.x, pos.y); | |
| } | |
| } | |
| onMouseUp() { | |
| // Handle waiting for drag detection | |
| if (this.waitingForDrag) { | |
| this.waitingForDrag = false; | |
| // Short click without drag - default to segmentation | |
| this.startSegmentation(this.initialClickPos); | |
| return; | |
| } | |
| // Rest of your existing mouse up logic... | |
| if (this.annotationMode === 'segmentation') { | |
| this.isDraggingEdge = false; | |
| this.draggingEdgeIndex = -1; | |
| this.isDraggingPoint = false; | |
| this.draggingPointIndex = -1; | |
| this.isDragging = false; | |
| this.canvas.style.cursor = 'crosshair'; | |
| return; | |
| } | |
| // Original bbox mouse up logic... | |
| if (this.isDrawing && this.currentBox) { | |
| 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); | |
| left = Math.max(0, left); | |
| top = Math.max(0, top); | |
| width = Math.min(width, this.originalWidth - left); | |
| height = Math.min(height, this.originalHeight - top); | |
| if (width > 10 && height > 10) { | |
| const box = { | |
| type: 'bbox', | |
| left: left, | |
| top: top, | |
| width: width, | |
| height: height, | |
| classId: parseInt(document.getElementById('classId').value) || 0, | |
| saved: false | |
| }; | |
| this.annotations.push(box); | |
| this.selectedBoxIndex = this.annotations.length - 1; | |
| document.getElementById('boxCount').textContent = this.annotations.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(); | |
| } | |
| updatePolygonCursor(x, y) { | |
| if (this.selectedBoxIndex >= 0 && this.annotations[this.selectedBoxIndex].type === 'segmentation') { | |
| const handle = this.getResizeHandle(x, y); | |
| this.canvas.style.cursor = handle.cursor; | |
| return; | |
| } | |
| // Check for unselected polygons | |
| const polygonIndex = this.getPolygonAtPosition(x, y); | |
| if (polygonIndex >= 0) { | |
| this.canvas.style.cursor = 'pointer'; | |
| } else { | |
| this.canvas.style.cursor = 'crosshair'; | |
| } | |
| } | |
| updateCursor(x, y) { | |
| if (this.selectedBoxIndex >= 0) { | |
| const handle = this.getResizeHandle(x, y); | |
| 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 { cursor: 'move' }; | |
| const annotation = this.annotations[this.selectedBoxIndex]; | |
| // Handle polygon cursor logic with directional resize cursors | |
| if (annotation.type === 'segmentation') { | |
| const tolerance = 8; | |
| // Check for corner points with directional resize cursors | |
| const pointIndex = this.getPolygonPointAtPosition(x, y, tolerance); | |
| if (pointIndex >= 0) { | |
| const resizeCursor = this.getPolygonCornerResizeCursor(pointIndex, annotation.points); | |
| return { cursor: resizeCursor, type: 'point', index: pointIndex }; | |
| } | |
| // Check for edges with directional resize cursors | |
| const edgeIndex = this.getPolygonEdgeAtPosition(x, y, 5); | |
| if (edgeIndex >= 0) { | |
| const edgeResizeCursor = this.getPolygonEdgeResizeCursor(edgeIndex, annotation.points); | |
| return { cursor: edgeResizeCursor, type: 'edge', index: edgeIndex }; | |
| } | |
| // Check if inside polygon (for moving entire polygon) | |
| if (this.isPointInPolygon(x, y, annotation.points)) { | |
| return { cursor: 'move', type: 'polygon-interior' }; | |
| } | |
| // Default cursor for segmentation mode | |
| return { cursor: 'crosshair', type: 'default' }; | |
| } | |
| // Original bbox logic remains the same | |
| const box = annotation; | |
| 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; | |
| } | |
| } | |
| // FIXED: Return proper object for interior clicks | |
| if (x >= box.left && x <= box.left + box.width && | |
| y >= box.top && y <= box.top + box.height) { | |
| return { cursor: 'move', type: 'interior' }; | |
| } | |
| return { cursor: 'default', type: 'default' }; | |
| } | |
| getPolygonCornerResizeCursor(pointIndex, points) { | |
| if (points.length < 3) return 'move'; | |
| // Calculate polygon bounding box | |
| const bounds = this.getPolygonBounds(points); | |
| const currentPoint = points[pointIndex]; | |
| // Determine position relative to bounding box center | |
| const centerX = bounds.left + bounds.width / 2; | |
| const centerY = bounds.top + bounds.height / 2; | |
| const isLeft = currentPoint.x < centerX; | |
| const isRight = currentPoint.x > centerX; | |
| const isTop = currentPoint.y < centerY; | |
| const isBottom = currentPoint.y > centerY; | |
| // Return appropriate resize cursor | |
| if (isTop && isLeft) return 'nw-resize'; | |
| if (isTop && isRight) return 'ne-resize'; | |
| if (isBottom && isLeft) return 'sw-resize'; | |
| if (isBottom && isRight) return 'se-resize'; | |
| if (isTop) return 'n-resize'; | |
| if (isBottom) return 's-resize'; | |
| if (isLeft) return 'w-resize'; | |
| if (isRight) return 'e-resize'; | |
| return 'move'; // Center point or fallback | |
| } | |
| getPolygonBounds(points) { | |
| if (points.length === 0) return { left: 0, top: 0, width: 0, height: 0 }; | |
| let minX = points[0].x, maxX = points[0].x; | |
| let minY = points[0].y, maxY = points[0].y; | |
| for (let i = 1; i < points.length; i++) { | |
| minX = Math.min(minX, points[i].x); | |
| maxX = Math.max(maxX, points[i].x); | |
| minY = Math.min(minY, points[i].y); | |
| maxY = Math.max(maxY, points[i].y); | |
| } | |
| return { | |
| left: minX, | |
| top: minY, | |
| width: maxX - minX, | |
| height: maxY - minY | |
| }; | |
| } | |
| getPolygonEdgeResizeCursor(edgeIndex, points) { | |
| if (points.length < 2) return 'move'; | |
| // Get the two points that form this edge | |
| const point1 = points[edgeIndex]; | |
| const point2 = points[(edgeIndex + 1) % points.length]; | |
| // Calculate edge direction | |
| const deltaX = Math.abs(point2.x - point1.x); | |
| const deltaY = Math.abs(point2.y - point1.y); | |
| // Determine if edge is more horizontal or vertical | |
| if (deltaX > deltaY) { | |
| // More horizontal edge - allow vertical resizing | |
| return 'ns-resize'; | |
| } else { | |
| // More vertical edge - allow horizontal resizing | |
| return 'ew-resize'; | |
| } | |
| } | |
| getBoxAtPosition(x, y) { | |
| for (let i = this.annotations.length - 1; i >= 0; i--) { | |
| const box = this.annotations[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.annotations[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.annotations.length) return; | |
| const box = this.annotations[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) { | |
| // Handle escape key for canceling drawing | |
| if (e.key === 'Escape') { | |
| e.preventDefault(); | |
| // Cancel polygon drawing if in progress | |
| if (this.annotationMode === 'segmentation' && this.isDrawingPolygon) { | |
| this.cancelPolygonDrawing(); | |
| return; | |
| } | |
| // Cancel bbox drawing if in progress | |
| if (this.annotationMode === 'bbox' && this.isDrawing) { | |
| this.cancelBboxDrawing(); | |
| return; | |
| } | |
| // Clear selection if nothing is being drawn | |
| this.selectedBoxIndex = -1; | |
| this.updateSelectedBoxInfo(); | |
| this.drawCanvas(); | |
| return; | |
| } | |
| if (e.key === 'Enter') { | |
| e.preventDefault(); | |
| this.onDoubleClick() | |
| return; | |
| } | |
| 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; | |
| } | |
| } | |
| cancelPolygonDrawing() { | |
| this.isDrawingPolygon = false; | |
| this.polygonPoints = []; | |
| this.currentPolygon = null; | |
| this.selectedBoxIndex = -1; | |
| this.updateSelectedBoxInfo(); | |
| this.drawCanvas(); | |
| this.showAlert('Polygon drawing canceled', 'info'); | |
| } | |
| cancelBboxDrawing() { | |
| this.isDrawing = false; | |
| this.currentBox = null; | |
| this.selectedBoxIndex = -1; | |
| this.updateSelectedBoxInfo(); | |
| this.drawCanvas(); | |
| this.showAlert('Bounding box drawing canceled', 'info'); | |
| } | |
| // New method to resize selected box | |
| resizeSelectedBox(deltaWidth, deltaHeight) { | |
| if (this.selectedBoxIndex < 0) return; | |
| const box = this.annotations[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.annotations.splice(this.selectedBoxIndex, 1); | |
| this.selectedBoxIndex = -1; | |
| document.getElementById('boxCount').textContent = this.annotations.length; | |
| this.updateSelectedBoxInfo(); | |
| this.drawCanvas(); | |
| // this.showAlert('Box deleted', 'info'); | |
| } | |
| } | |
| // Enhanced updateSelectedBoxInfo method | |
| updateSelectedBoxInfo() { | |
| const selectedBoxInfo = document.getElementById('selectedBoxInfo'); | |
| const editPanel = document.getElementById('annotationEditPanel'); | |
| if (this.selectedBoxIndex >= 0) { | |
| const annotation = this.annotations[this.selectedBoxIndex]; | |
| if (annotation.type === 'segmentation') { | |
| const bounds = this.getPolygonBounds(annotation.points); | |
| const pointCount = annotation.points.length; | |
| selectedBoxInfo.textContent = `#${this.selectedBoxIndex + 1} Polygon (${pointCount} points, ${Math.round(bounds.left)}, ${Math.round(bounds.top)}, ${Math.round(bounds.width)}×${Math.round(bounds.height)})`; | |
| } else { | |
| selectedBoxInfo.textContent = `#${this.selectedBoxIndex + 1} BBox (${Math.round(annotation.left)}, ${Math.round(annotation.top)}, ${Math.round(annotation.width)}×${Math.round(annotation.height)})`; | |
| } | |
| // Show and populate edit panel | |
| this.showEditPanel(annotation); | |
| editPanel.style.display = 'block'; | |
| } else { | |
| selectedBoxInfo.textContent = 'None'; | |
| editPanel.style.display = 'none'; | |
| } | |
| } | |
| // New method to show edit panel | |
| showEditPanel(annotation) { | |
| const editType = document.getElementById('editType'); | |
| const editClassId = document.getElementById('editClassId'); | |
| const bboxControls = document.getElementById('bboxEditControls'); | |
| const polygonControls = document.getElementById('polygonEditControls'); | |
| // Set common fields | |
| editType.textContent = annotation.type === 'segmentation' ? 'Polygon' : 'Bounding Box'; | |
| editClassId.value = annotation.classId || 0; | |
| if (annotation.type === 'bbox') { | |
| // Show bbox controls | |
| bboxControls.style.display = 'block'; | |
| polygonControls.style.display = 'none'; | |
| document.getElementById('editLeft').value = Math.round(annotation.left); | |
| document.getElementById('editTop').value = Math.round(annotation.top); | |
| document.getElementById('editWidth').value = Math.round(annotation.width); | |
| document.getElementById('editHeight').value = Math.round(annotation.height); | |
| // Set max values based on current position | |
| document.getElementById('editLeft').max = this.originalWidth - annotation.width; | |
| document.getElementById('editTop').max = this.originalHeight - annotation.height; | |
| document.getElementById('editWidth').max = this.originalWidth - annotation.left; | |
| document.getElementById('editHeight').max = this.originalHeight - annotation.top; | |
| } else { | |
| // Show polygon controls | |
| bboxControls.style.display = 'none'; | |
| polygonControls.style.display = 'block'; | |
| this.populatePolygonPointsList(annotation.points); | |
| } | |
| } | |
| // New method to populate polygon points list | |
| // Enhanced method to populate polygon points list with hover highlighting | |
| populatePolygonPointsList(points) { | |
| const pointsList = document.getElementById('pointsList'); | |
| const pointCount = document.getElementById('pointCount'); | |
| pointCount.textContent = points.length; | |
| pointsList.innerHTML = ''; | |
| points.forEach((point, index) => { | |
| const row = document.createElement('div'); | |
| row.className = 'point-edit-row'; | |
| row.setAttribute('data-point-index', index); | |
| row.innerHTML = ` | |
| <span style="font-size: 15px; color: #4a5568; min-width: 15px;">${index + 1}:</span> | |
| <input type="number" class="point-edit-input" value="${Math.round(point.x)}" | |
| data-index="${index}" data-coord="x" min="0" max="${this.originalWidth}"> | |
| <input type="number" class="point-edit-input" value="${Math.round(point.y)}" | |
| data-index="${index}" data-coord="y" min="0" max="${this.originalHeight}"> | |
| <button class="point-delete-btn" data-index="${index}" | |
| ${points.length <= 3 ? 'disabled title="Cannot delete - minimum 3 points required"' : ''}> | |
| × | |
| </button> | |
| `; | |
| pointsList.appendChild(row); | |
| }); | |
| // Add event listeners for point inputs | |
| pointsList.querySelectorAll('.point-edit-input').forEach(input => { | |
| input.addEventListener('change', (e) => this.updatePolygonPoint(e)); | |
| // Add hover event listeners for highlighting | |
| input.addEventListener('mouseenter', (e) => this.highlightPolygonPoint(e)); | |
| input.addEventListener('mouseleave', (e) => this.unhighlightPolygonPoint(e)); | |
| input.addEventListener('focus', (e) => this.highlightPolygonPoint(e)); | |
| input.addEventListener('blur', (e) => this.unhighlightPolygonPoint(e)); | |
| }); | |
| // Add event listeners for delete buttons | |
| pointsList.querySelectorAll('.point-delete-btn').forEach(btn => { | |
| btn.addEventListener('click', (e) => this.deletePolygonPoint(e)); | |
| // Add hover event listeners for delete buttons | |
| btn.addEventListener('mouseenter', (e) => this.highlightPolygonPoint(e)); | |
| btn.addEventListener('mouseleave', (e) => this.unhighlightPolygonPoint(e)); | |
| }); | |
| // Add hover listeners for entire rows | |
| pointsList.querySelectorAll('.point-edit-row').forEach(row => { | |
| row.addEventListener('mouseenter', (e) => this.highlightPolygonPointByRow(e)); | |
| row.addEventListener('mouseleave', (e) => this.unhighlightPolygonPointByRow(e)); | |
| }); | |
| } | |
| // Method to highlight a polygon point on canvas | |
| highlightPolygonPoint(e) { | |
| const index = parseInt(e.target.dataset.index); | |
| if (this.selectedBoxIndex >= 0 && index >= 0) { | |
| this.highlightedPointIndex = index; | |
| // Highlight the row in sidebar | |
| const row = e.target.closest('.point-edit-row'); | |
| if (row) { | |
| row.classList.add('highlighted'); | |
| row.querySelectorAll('.point-edit-input').forEach(input => { | |
| input.classList.add('highlighted'); | |
| }); | |
| } | |
| this.drawCanvas(); | |
| } | |
| } | |
| // Method to unhighlight a polygon point | |
| unhighlightPolygonPoint(e) { | |
| this.highlightedPointIndex = -1; | |
| // Remove highlight from sidebar row | |
| const row = e.target.closest('.point-edit-row'); | |
| if (row) { | |
| row.classList.remove('highlighted'); | |
| row.querySelectorAll('.point-edit-input').forEach(input => { | |
| input.classList.remove('highlighted'); | |
| }); | |
| } | |
| this.drawCanvas(); | |
| } | |
| // Method to highlight by hovering over entire row | |
| highlightPolygonPointByRow(e) { | |
| const row = e.currentTarget; | |
| const index = parseInt(row.dataset.pointIndex); | |
| if (index >= 0) { | |
| this.highlightedPointIndex = index; | |
| row.classList.add('highlighted'); | |
| row.querySelectorAll('.point-edit-input').forEach(input => { | |
| input.classList.add('highlighted'); | |
| }); | |
| this.drawCanvas(); | |
| } | |
| } | |
| // Method to unhighlight by leaving row | |
| unhighlightPolygonPointByRow(e) { | |
| const row = e.currentTarget; | |
| this.highlightedPointIndex = -1; | |
| row.classList.remove('highlighted'); | |
| row.querySelectorAll('.point-edit-input').forEach(input => { | |
| input.classList.remove('highlighted'); | |
| }); | |
| this.drawCanvas(); | |
| } | |
| // New method to update polygon point | |
| updatePolygonPoint(e) { | |
| const index = parseInt(e.target.dataset.index); | |
| const coord = e.target.dataset.coord; | |
| const value = parseInt(e.target.value); | |
| if (this.selectedBoxIndex >= 0) { | |
| const annotation = this.annotations[this.selectedBoxIndex]; | |
| if (annotation.type === 'segmentation' && annotation.points[index]) { | |
| annotation.points[index][coord] = Math.max(0, | |
| Math.min(coord === 'x' ? this.originalWidth : this.originalHeight, value)); | |
| annotation.saved = false; | |
| this.drawCanvas(); | |
| } | |
| } | |
| } | |
| // New method to delete polygon point | |
| deletePolygonPoint(e) { | |
| const index = parseInt(e.target.dataset.index); | |
| if (this.selectedBoxIndex >= 0) { | |
| const annotation = this.annotations[this.selectedBoxIndex]; | |
| if (annotation.type === 'segmentation' && annotation.points.length > 3) { | |
| annotation.points.splice(index, 1); | |
| annotation.saved = false; | |
| this.populatePolygonPointsList(annotation.points); | |
| this.updateSelectedBoxInfo(); | |
| this.drawCanvas(); | |
| } | |
| } | |
| } | |
| // Method to apply edits | |
| applyEdits() { | |
| if (this.selectedBoxIndex >= 0) { | |
| const annotation = this.annotations[this.selectedBoxIndex]; | |
| const newClassId = parseInt(document.getElementById('editClassId').value) || 0; | |
| // Update class ID | |
| annotation.classId = newClassId; | |
| if (annotation.type === 'bbox') { | |
| // Update bbox properties | |
| const newLeft = parseInt(document.getElementById('editLeft').value); | |
| const newTop = parseInt(document.getElementById('editTop').value); | |
| const newWidth = parseInt(document.getElementById('editWidth').value); | |
| const newHeight = parseInt(document.getElementById('editHeight').value); | |
| // Apply boundary constraints | |
| annotation.left = Math.max(0, Math.min(this.originalWidth - newWidth, newLeft)); | |
| annotation.top = Math.max(0, Math.min(this.originalHeight - newHeight, newTop)); | |
| annotation.width = Math.max(10, Math.min(this.originalWidth - annotation.left, newWidth)); | |
| annotation.height = Math.max(10, Math.min(this.originalHeight - annotation.top, newHeight)); | |
| } | |
| annotation.saved = false; | |
| this.updateSelectedBoxInfo(); | |
| this.drawCanvas(); | |
| this.showAlert('Annotation updated', 'success'); | |
| } | |
| } | |
| // Method to cancel edits | |
| cancelEdits() { | |
| // Just refresh the edit panel with current values | |
| if (this.selectedBoxIndex >= 0) { | |
| this.showEditPanel(this.annotations[this.selectedBoxIndex]); | |
| } | |
| } | |
| // Method for real-time bbox updates | |
| updateBboxFromInputs() { | |
| if (this.selectedBoxIndex >= 0) { | |
| const annotation = this.annotations[this.selectedBoxIndex]; | |
| if (annotation.type === 'bbox') { | |
| const newLeft = parseInt(document.getElementById('editLeft').value) || 0; | |
| const newTop = parseInt(document.getElementById('editTop').value) || 0; | |
| const newWidth = parseInt(document.getElementById('editWidth').value) || 10; | |
| const newHeight = parseInt(document.getElementById('editHeight').value) || 10; | |
| // Apply constraints and update | |
| annotation.left = Math.max(0, Math.min(this.originalWidth - newWidth, newLeft)); | |
| annotation.top = Math.max(0, Math.min(this.originalHeight - newHeight, newTop)); | |
| annotation.width = Math.max(10, Math.min(this.originalWidth - annotation.left, newWidth)); | |
| annotation.height = Math.max(10, Math.min(this.originalHeight - annotation.top, newHeight)); | |
| annotation.saved = false; | |
| this.drawCanvas(); | |
| } | |
| } | |
| } | |
| 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 | |
| }; | |
| } | |
| // Update the save method to handle new format | |
| async saveAnnotations() { | |
| if (!this.currentImage || this.annotations.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({ | |
| annotations: this.annotations.map(ann => ({ ...ann, saved: true })), | |
| image_name: this.currentImage, | |
| original_width: this.originalWidth, | |
| original_height: this.originalHeight | |
| }) | |
| }); | |
| if (response.ok) { | |
| const result = await response.json(); | |
| this.annotations.forEach(box => box.saved = true); | |
| this.drawCanvas(); | |
| this.showAlert(result.message, 'success'); | |
| this.loadImages(); | |
| } else { | |
| throw new Error('Failed to save annotations'); | |
| } | |
| } catch (error) { | |
| this.showAlert('Error saving annotations: ' + error.message, 'error'); | |
| } | |
| } | |
| undoLastBox() { | |
| if (this.annotations.length > 0) { | |
| this.annotations.pop(); | |
| this.selectedBoxIndex = -1; | |
| document.getElementById('boxCount').textContent = this.annotations.length; | |
| this.updateSelectedBoxInfo(); | |
| this.drawCanvas(); | |
| this.showAlert('Last box removed', 'info'); | |
| } else { | |
| this.showAlert('No annotations to undo', 'info'); | |
| } | |
| } | |
| async clearAllAnnotations() { | |
| if (!this.currentImage) return; | |
| if (confirm('Are you sure you want to clear all annotations and delete the annotation file?')) { | |
| try { | |
| // Delete annotations from server | |
| await fetch(`/api/annotate/annotations/${encodeURIComponent(this.currentImage)}`, { | |
| method: 'DELETE' | |
| }); | |
| this.annotations = []; | |
| this.selectedBoxIndex = -1; | |
| document.getElementById('boxCount').textContent = '0'; | |
| this.updateSelectedBoxInfo(); | |
| this.drawCanvas(); | |
| this.showAlert('All annotations 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.annotations = (data.annotations || []).map(box => ({ | |
| ...box, | |
| saved: true | |
| })); | |
| this.selectedBoxIndex = -1; | |
| document.getElementById('boxCount').textContent = this.annotations.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.annotations = (data.annotations || []).map(box => ({ | |
| ...box, | |
| saved: true | |
| })); | |
| this.selectedBoxIndex = -1; | |
| document.getElementById('boxCount').textContent = this.annotations.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 { | |
| this.showModeIndicator('File Upload is in progress', 'Uploading File', true); | |
| const response = await fetch('/api/annotate/upload', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| if (response.ok) { | |
| const result = await response.json(); | |
| await this.loadImages(); | |
| // Auto-select the uploaded image | |
| var new_file_name = file.name | |
| const index = this.images.findIndex(img => img.name === new_file_name); | |
| if (index >= 0) { | |
| this.currentImageIndex = index; | |
| document.getElementById('imageSelect').value = new_file_name; | |
| this.showModeIndicator(result.message, 'Uploading File'); | |
| this.loadImage(new_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); | |
| } | |
| ////////////////////////// ----train---- ////////////////////////// | |
| openXterm() { | |
| const modal = document.getElementById('outputModal'); | |
| modal.style.display = 'block'; | |
| // Initialize terminal on first run | |
| if (!term) { | |
| term = new Terminal({ | |
| cursorBlink: true, | |
| convertEol: true, | |
| theme: { | |
| background: '#000000', | |
| foreground: '#00FF7F', // SpringGreen | |
| cursor: 'rgba(255, 255, 255, 0.5)' | |
| } | |
| }); | |
| fitAddon = new FitAddon.FitAddon(); | |
| const webLinksAddon = new WebLinksAddon.WebLinksAddon(); | |
| term.loadAddon(fitAddon); | |
| term.loadAddon(webLinksAddon); | |
| term.open(document.getElementById('output')); | |
| } | |
| // Use a short timeout to ensure the modal is visible before fitting | |
| setTimeout(() => fitAddon.fit(), 50); | |
| term.clear(); | |
| term.focus(); | |
| term.write('\x1b[33mRunning command...\x1b[0m\r\n'); | |
| } | |
| clearOutput() { | |
| if (term) { | |
| term.clear(); | |
| } | |
| } | |
| stopTrain() { | |
| fetch('/api/annotate/stopTrain', { | |
| method: 'GET', | |
| headers: { 'Content-Type': 'application/json' } | |
| }) | |
| } | |
| closeTrainModal() { | |
| const modal = document.getElementById('outputModal'); | |
| modal.style.display = 'none'; | |
| } | |
| } | |
| // Initialize the application when the page loads | |
| document.addEventListener('DOMContentLoaded', () => { | |
| new ComicAnnotator(); | |
| }); | |
| </script> | |
| </body> | |
| </html> |