| <!DOCTYPE html> |
| <html lang="en"> |
|
|
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>📸 Annotator</title> |
| |
| <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-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-container { |
| display: flex; |
| height: calc(100vh - 72px); |
| overflow: hidden; |
| } |
| |
| |
| .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 { |
| 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; |
| |
| 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-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); |
| } |
| |
| |
| .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; |
| } |
| |
| |
| .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-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-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 { |
| 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 { |
| 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; |
| } |
| } |
| |
| |
| .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; |
| } |
| |
| |
| @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; |
| } |
| |
| |
| .point-highlighted { |
| background: rgba(255, 215, 0, 0.8) !important; |
| border: 3px solid #ff6b00 !important; |
| box-shadow: 0 0 10px rgba(255, 107, 0, 0.6) !important; |
| } |
| |
| .point-edit-row.highlighted { |
| background: #fff3cd !important; |
| border: 2px solid #ffc107 !important; |
| border-radius: 6px !important; |
| box-shadow: 0 2px 8px rgba(255, 193, 7, 0.3) !important; |
| } |
| |
| .point-edit-input.highlighted { |
| border-color: #ffc107 !important; |
| background: #fff3cd !important; |
| } |
| |
| .modal { |
| display: none; |
| |
| position: fixed; |
| |
| z-index: 1000; |
| |
| left: 0; |
| top: 0; |
| width: 100%; |
| |
| height: 100%; |
| |
| overflow: auto; |
| |
| |
| background-color: rgba(0, 0, 0, 0.8); |
| } |
| |
| .modal-content { |
| background-color: #fefefe; |
| |
| margin: 2% 0 0 28%; |
| |
| padding: 20px; |
| border: 1px solid #888; |
| |
| width: 80%; |
| |
| max-width: 600px; |
| |
| border-radius: 8px; |
| |
| box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); |
| |
| 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; |
| |
| 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> |
| |
| <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"> |
| |
| <div class="sidebar"> |
| |
| <div class="sidebar-section"> |
| <div class="section-title" style="display: none;">Image Selection</div> |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| <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> |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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;"> |
| |
| </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> |
|
|
|
|
| |
| <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> |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| </div> |
|
|
| |
| <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> |
|
|
| |
| <div class="canvas-area"> |
| <div class="canvas-toolbar"> |
| |
| |
| |
| <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> |
|
|
| |
| <div class="alerts" id="alerts"></div> |
|
|
| |
| <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> |
|
|
| |
| <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"> |
| |
| <button class="stop-button" id="stopTrain">Stop</button> |
| <button class="clear-button" id="clearOutput">Clear</button> |
| </div> |
| |
| <div id="output"></div> |
| </div> |
| </div> |
| <script> |
| |
| |
| 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 { |
| |
| 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; |
| |
| |
| this.isDrawing = false; |
| this.startX = 0; |
| this.startY = 0; |
| this.currentBox = null; |
| |
| |
| this.annotationMode = 'segmentation'; |
| this.currentPolygon = null; |
| this.isDrawingPolygon = false; |
| this.polygonPoints = []; |
| |
| |
| this.selectedBoxIndex = -1; |
| this.isDragging = false; |
| this.isResizing = false; |
| this.dragStartX = 0; |
| this.dragStartY = 0; |
| this.resizeHandle = null; |
| this.lastMouseX = 0; |
| this.lastMouseY = 0; |
| |
| |
| this.isDraggingEdge = false; |
| this.draggingEdgeIndex = -1; |
| this.edgeResizeTolerance = 8; |
| this.showEdgeHandles = true; |
| |
| |
| this.waitingForDrag = false; |
| this.initialClickPos = null; |
| this.clickStartTime = 0; |
| this.modeIndicatorTimeout = null; |
| |
| |
| this.highlightedPointIndex = -1; |
| |
| this.init(); |
| } |
| |
| init() { |
| this.setupEventListeners(); |
| this.loadImages(); |
| this.loadTrainConfig(); |
| } |
| |
| setupEventListeners() { |
| |
| document.getElementById('annotationMode').addEventListener('change', (e) => { |
| this.annotationMode = e.target.value; |
| this.selectedBoxIndex = -1; |
| this.polygonPoints = []; |
| this.isDrawingPolygon = false; |
| this.updateCanvasCursor(); |
| this.drawCanvas(); |
| }); |
| |
| |
| 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); |
| } |
| } |
| }); |
| |
| |
| document.getElementById('prevBtn').addEventListener('click', () => this.navigatePrevious()); |
| document.getElementById('nextBtn').addEventListener('click', () => this.navigateNext()); |
| |
| |
| document.getElementById('uploadFile').addEventListener('change', (e) => { |
| if (e.target.files[0]) { |
| this.uploadImage(e.target.files[0]); |
| } |
| }); |
| |
| |
| 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()); |
| |
| |
| 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)); |
| |
| |
| document.addEventListener('keydown', (e) => this.onKeyDown(e)); |
| |
| |
| document.getElementById('applyEditBtn').addEventListener('click', () => this.applyEdits()); |
| document.getElementById('cancelEditBtn').addEventListener('click', () => this.cancelEdits()); |
| document.getElementById('deleteEditBtn').addEventListener('click', () => this.deleteSelectedBox()); |
| |
| |
| 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()); |
| |
| |
| 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; |
| } |
| }); |
| |
| |
| |
| 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'); |
| } |
| } |
| |
| |
| 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() |
| }); |
| |
| |
| window.addEventListener('resize', () => { |
| if (document.getElementById('outputModal').style.display === 'block' && fitAddon) { |
| try { |
| fitAddon.fit(); |
| } catch (e) { |
| console.error("Error fitting terminal on resize:", e); |
| } |
| } |
| }); |
| |
| |
| 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'); |
| } |
| }); |
| |
| |
| |
| 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'; |
| }); |
| |
| |
| 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); |
| }); |
| |
| |
| 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; |
| |
| |
| 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; |
| |
| |
| |
| |
| |
| |
| |
| } |
| |
| navigatePrevious() { |
| if (this.currentImageIndex > 0) { |
| this.currentImageIndex--; |
| const imageName = this.images[this.currentImageIndex].name; |
| document.getElementById('imageSelect').value = 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; |
| |
| this.loadImage(imageName); |
| } |
| } |
| |
| resetAnnotationStates() { |
| |
| this.selectedBoxIndex = -1; |
| |
| |
| this.isDrawing = false; |
| this.isDragging = false; |
| this.isResizing = false; |
| this.isDraggingEdge = false; |
| this.isDraggingPoint = false; |
| |
| |
| this.isDrawingPolygon = false; |
| this.polygonPoints = []; |
| this.currentPolygon = null; |
| |
| |
| this.currentBox = null; |
| this.resizeHandle = null; |
| |
| |
| this.draggingEdgeIndex = -1; |
| this.draggingPointIndex = -1; |
| this.dragStartX = 0; |
| this.dragStartY = 0; |
| this.lastMouseX = 0; |
| this.lastMouseY = 0; |
| |
| |
| this.waitingForDrag = false; |
| this.initialClickPos = null; |
| this.clickStartTime = 0; |
| this.highlightedPointIndex = -1; |
| |
| |
| this.canvas.style.cursor = 'crosshair'; |
| |
| |
| const editPanel = document.getElementById('annotationEditPanel'); |
| if (editPanel) { |
| editPanel.style.display = 'none'; |
| } |
| } |
| |
| |
| async loadImage(imageName) { |
| try { |
| this.resetAnnotationStates(); |
| |
| const imageResponse = await fetch(`/api/annotate/image/${encodeURIComponent(imageName)}`); |
| const imageData = await imageResponse.json(); |
| |
| |
| 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; |
| |
| |
| const img = new Image(); |
| img.onload = () => { |
| this.backgroundImage = img; |
| |
| |
| 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; |
| |
| |
| 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); |
| } |
| |
| |
| 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); |
| } |
| |
| |
| if (index === this.selectedBoxIndex && annotation.type === 'bbox') { |
| this.drawResizeHandles(annotation); |
| } |
| }); |
| |
| |
| if (this.currentBox) { |
| this.drawBox(this.currentBox.left, this.currentBox.top, this.currentBox.width, this.currentBox.height, '#ff0000', 'rgba(255, 0, 0, 0.3)'); |
| } |
| |
| |
| 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(); |
| |
| |
| const handleSize = isSelected ? 10 : 6; |
| points.forEach((point, index) => { |
| |
| const isHighlighted = isSelected && this.highlightedPointIndex === index; |
| |
| if (isHighlighted) { |
| |
| this.ctx.fillStyle = '#FFD700'; |
| this.ctx.strokeStyle = '#FF6B00'; |
| this.ctx.lineWidth = 3; |
| |
| |
| 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 |
| ); |
| |
| |
| 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; |
| |
| } else { |
| |
| 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 |
| ); |
| } |
| } |
| }); |
| |
| |
| 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; |
| |
| |
| 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); |
| } |
| } |
| |
| |
| 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; |
| } |
| |
| |
| 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; |
| |
| |
| const edgeVectorX = points[nextIndex].x - points[edgeIndex].x; |
| const edgeVectorY = points[nextIndex].y - points[edgeIndex].y; |
| |
| |
| const edgeLength = Math.sqrt(edgeVectorX * edgeVectorX + edgeVectorY * edgeVectorY); |
| if (edgeLength === 0) return; |
| |
| const normalX = -edgeVectorY / edgeLength; |
| const normalY = edgeVectorX / edgeLength; |
| |
| |
| const edgeMidX = (points[edgeIndex].x + points[nextIndex].x) / 2; |
| const edgeMidY = (points[edgeIndex].y + points[nextIndex].y) / 2; |
| |
| |
| const mouseVectorX = mouseX - edgeMidX; |
| const mouseVectorY = mouseY - edgeMidY; |
| |
| |
| const moveDistance = mouseVectorX * normalX + mouseVectorY * normalY; |
| |
| |
| 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; |
| } |
| |
| |
| 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 -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); |
| |
| |
| if (this.selectedBoxIndex >= 0) { |
| const annotation = this.annotations[this.selectedBoxIndex]; |
| |
| if (annotation.type === 'segmentation') { |
| |
| 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 (handle && handle.type !== 'default' && handle.type !== 'interior') { |
| this.isResizing = true; |
| this.resizeHandle = handle; |
| this.canvas.style.cursor = handle.cursor; |
| return; |
| } |
| |
| |
| if (handle && handle.type === 'interior') { |
| this.isDragging = true; |
| this.dragStartX = pos.x; |
| this.dragStartY = pos.y; |
| this.canvas.style.cursor = 'move'; |
| this.updateSelectedBoxInfo(); |
| return; |
| } |
| } |
| } |
| |
| |
| if (this.annotationMode === 'segmentation' && this.isDrawingPolygon) { |
| this.polygonPoints.push(pos); |
| this.currentPolygon.points = [...this.polygonPoints]; |
| this.drawCanvas(); |
| return; |
| } |
| |
| |
| 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; |
| } |
| |
| |
| this.selectedBoxIndex = -1; |
| this.updateSelectedBoxInfo(); |
| |
| |
| if (e.ctrlKey) { |
| |
| this.startSegmentation(pos); |
| } else if (e.shiftKey) { |
| |
| this.startBboxDrawing(pos); |
| } else { |
| |
| 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) { |
| |
| 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'; |
| |
| |
| 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) { |
| |
| 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); |
| |
| |
| 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 (dragDistance > 10) { |
| this.waitingForDrag = false; |
| this.startBboxDrawing(this.initialClickPos); |
| |
| this.currentBox.width = pos.x - this.startX; |
| this.currentBox.height = pos.y - this.startY; |
| this.drawCanvas(); |
| return; |
| } |
| |
| |
| if (timeSinceClick > 500) { |
| this.waitingForDrag = false; |
| this.startSegmentation(this.initialClickPos); |
| return; |
| } |
| |
| return; |
| } |
| |
| |
| 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; |
| } |
| |
| |
| 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() { |
| |
| if (this.waitingForDrag) { |
| this.waitingForDrag = false; |
| |
| this.startSegmentation(this.initialClickPos); |
| return; |
| } |
| |
| |
| 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; |
| } |
| |
| |
| 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; |
| } |
| |
| |
| 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]; |
| |
| |
| if (annotation.type === 'segmentation') { |
| const tolerance = 8; |
| |
| |
| const pointIndex = this.getPolygonPointAtPosition(x, y, tolerance); |
| if (pointIndex >= 0) { |
| const resizeCursor = this.getPolygonCornerResizeCursor(pointIndex, annotation.points); |
| return { cursor: resizeCursor, type: 'point', index: pointIndex }; |
| } |
| |
| |
| const edgeIndex = this.getPolygonEdgeAtPosition(x, y, 5); |
| if (edgeIndex >= 0) { |
| const edgeResizeCursor = this.getPolygonEdgeResizeCursor(edgeIndex, annotation.points); |
| return { cursor: edgeResizeCursor, type: 'edge', index: edgeIndex }; |
| } |
| |
| |
| if (this.isPointInPolygon(x, y, annotation.points)) { |
| return { cursor: 'move', type: 'polygon-interior' }; |
| } |
| |
| |
| return { cursor: 'crosshair', type: 'default' }; |
| } |
| |
| |
| 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; |
| } |
| } |
| |
| |
| 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'; |
| |
| |
| const bounds = this.getPolygonBounds(points); |
| const currentPoint = points[pointIndex]; |
| |
| |
| 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; |
| |
| |
| 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'; |
| } |
| 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'; |
| |
| |
| const point1 = points[edgeIndex]; |
| const point2 = points[(edgeIndex + 1) % points.length]; |
| |
| |
| const deltaX = Math.abs(point2.x - point1.x); |
| const deltaY = Math.abs(point2.y - point1.y); |
| |
| |
| if (deltaX > deltaY) { |
| |
| return 'ns-resize'; |
| } else { |
| |
| 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]; |
| |
| |
| let newLeft = box.left + deltaX; |
| let newTop = box.top + deltaY; |
| |
| |
| newLeft = Math.max(0, Math.min(this.originalWidth - box.width, newLeft)); |
| newTop = Math.max(0, Math.min(this.originalHeight - box.height, newTop)); |
| |
| |
| box.left = newLeft; |
| box.top = newTop; |
| |
| box.saved = false; |
| this.updateSelectedBoxInfo(); |
| } |
| |
| |
| onKeyDown(e) { |
| |
| if (e.key === 'Escape') { |
| e.preventDefault(); |
| |
| |
| if (this.annotationMode === 'segmentation' && this.isDrawingPolygon) { |
| this.cancelPolygonDrawing(); |
| return; |
| } |
| |
| |
| if (this.annotationMode === 'bbox' && this.isDrawing) { |
| this.cancelBboxDrawing(); |
| return; |
| } |
| |
| |
| 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; |
| const moveDistance = e.shiftKey ? 10 : 1; |
| |
| if (e.shiftKey) { |
| |
| 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 { |
| |
| 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; |
| } |
| } |
| |
| |
| switch (e.key) { |
| case 'Delete': |
| |
| 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'); |
| } |
| |
| |
| |
| resizeSelectedBox(deltaWidth, deltaHeight) { |
| if (this.selectedBoxIndex < 0) return; |
| |
| const box = this.annotations[this.selectedBoxIndex]; |
| |
| |
| let newWidth = box.width + deltaWidth; |
| let newHeight = box.height + deltaHeight; |
| |
| |
| if (newWidth >= 10) { |
| |
| newWidth = Math.min(newWidth, this.originalWidth - box.left); |
| box.width = newWidth; |
| |
| |
| if (box.left + box.width > this.originalWidth) { |
| box.left = this.originalWidth - box.width; |
| } |
| } |
| |
| |
| if (newHeight >= 10) { |
| |
| newHeight = Math.min(newHeight, this.originalHeight - box.top); |
| box.height = newHeight; |
| |
| |
| 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(); |
| |
| } |
| } |
| |
| |
| 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)})`; |
| } |
| |
| |
| this.showEditPanel(annotation); |
| editPanel.style.display = 'block'; |
| } else { |
| selectedBoxInfo.textContent = 'None'; |
| editPanel.style.display = 'none'; |
| } |
| } |
| |
| |
| showEditPanel(annotation) { |
| const editType = document.getElementById('editType'); |
| const editClassId = document.getElementById('editClassId'); |
| const bboxControls = document.getElementById('bboxEditControls'); |
| const polygonControls = document.getElementById('polygonEditControls'); |
| |
| |
| editType.textContent = annotation.type === 'segmentation' ? 'Polygon' : 'Bounding Box'; |
| editClassId.value = annotation.classId || 0; |
| |
| if (annotation.type === 'bbox') { |
| |
| 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); |
| |
| |
| 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 { |
| |
| bboxControls.style.display = 'none'; |
| polygonControls.style.display = 'block'; |
| |
| this.populatePolygonPointsList(annotation.points); |
| } |
| } |
| |
| |
| |
| 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); |
| }); |
| |
| |
| pointsList.querySelectorAll('.point-edit-input').forEach(input => { |
| input.addEventListener('change', (e) => this.updatePolygonPoint(e)); |
| |
| |
| 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)); |
| }); |
| |
| |
| pointsList.querySelectorAll('.point-delete-btn').forEach(btn => { |
| btn.addEventListener('click', (e) => this.deletePolygonPoint(e)); |
| |
| |
| btn.addEventListener('mouseenter', (e) => this.highlightPolygonPoint(e)); |
| btn.addEventListener('mouseleave', (e) => this.unhighlightPolygonPoint(e)); |
| }); |
| |
| |
| pointsList.querySelectorAll('.point-edit-row').forEach(row => { |
| row.addEventListener('mouseenter', (e) => this.highlightPolygonPointByRow(e)); |
| row.addEventListener('mouseleave', (e) => this.unhighlightPolygonPointByRow(e)); |
| }); |
| } |
| |
| highlightPolygonPoint(e) { |
| const index = parseInt(e.target.dataset.index); |
| if (this.selectedBoxIndex >= 0 && index >= 0) { |
| this.highlightedPointIndex = index; |
| |
| |
| 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(); |
| } |
| } |
| |
| |
| unhighlightPolygonPoint(e) { |
| this.highlightedPointIndex = -1; |
| |
| |
| 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(); |
| } |
| |
| |
| 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(); |
| } |
| } |
| |
| |
| 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(); |
| } |
| |
| |
| |
| 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(); |
| } |
| } |
| } |
| |
| |
| 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(); |
| } |
| } |
| } |
| |
| |
| applyEdits() { |
| if (this.selectedBoxIndex >= 0) { |
| const annotation = this.annotations[this.selectedBoxIndex]; |
| const newClassId = parseInt(document.getElementById('editClassId').value) || 0; |
| |
| |
| annotation.classId = newClassId; |
| |
| if (annotation.type === 'bbox') { |
| |
| 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); |
| |
| |
| 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'); |
| } |
| } |
| |
| |
| cancelEdits() { |
| |
| if (this.selectedBoxIndex >= 0) { |
| this.showEditPanel(this.annotations[this.selectedBoxIndex]); |
| } |
| } |
| |
| 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; |
| |
| |
| 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 |
| }; |
| } |
| |
| |
| 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 { |
| |
| 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(); |
| } 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(); |
| |
| |
| 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'); |
| } |
| |
| |
| 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); |
| |
| |
| setTimeout(() => { |
| if (alert.parentNode) { |
| alert.parentNode.removeChild(alert); |
| } |
| }, 5000); |
| } |
| |
| openXterm() { |
| const modal = document.getElementById('outputModal'); |
| modal.style.display = 'block'; |
| |
| if (!term) { |
| |
| term = new Terminal({ |
| cursorBlink: true, |
| convertEol: true, |
| theme: { |
| background: '#000000', |
| foreground: '#00FF7F', |
| 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')); |
| } |
| |
| |
| 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'; |
| } |
| } |
| |
| |
| document.addEventListener('DOMContentLoaded', () => { |
| new ComicAnnotator(); |
| }); |
| </script> |
| </body> |
|
|
| </html> |