Spaces:
Sleeping
Sleeping
| {% extends "base.html" %} | |
| {% block title %}SV2 - MV+{% endblock %} | |
| {% block content %} | |
| <!-- Load base64 images fallback FIRST, before any images try to load --> | |
| <!-- Use blocking script (no async/defer) to ensure variables are available immediately --> | |
| <script src="{{ url_for('static', filename='images_base64.js') }}" type="text/javascript"></script> | |
| <script type="text/javascript"> | |
| // Verify base64 variables are loaded and pre-set image sources to avoid 404s | |
| (function() { | |
| if (typeof window === 'undefined' || !window.HP_GIF_DATA_URI || !window.WHITEBOARD_PNG_DATA_URI) { | |
| console.error('Base64 images script failed to load or variables not defined'); | |
| } else { | |
| console.log('Base64 fallback ready: HP_GIF_DATA_URI and WHITEBOARD_PNG_DATA_URI available'); | |
| // Pre-set image sources to base64 to avoid 404s on Hugging Face | |
| // This runs after DOM is ready | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', setBase64Images); | |
| } else { | |
| setBase64Images(); | |
| } | |
| function setBase64Images() { | |
| const hpImg = document.getElementById('hpGifImage'); | |
| const wbImg = document.getElementById('whiteboardImage'); | |
| if (hpImg && window.HP_GIF_DATA_URI) { | |
| // Try static first, but immediately fallback to base64 | |
| hpImg.onerror = function() { | |
| console.log('Loading hp.gif from base64 fallback'); | |
| this.src = window.HP_GIF_DATA_URI; | |
| this.onload = function() { console.log('hp.gif loaded successfully from base64'); }; | |
| this.onerror = function() { console.error('Failed to load hp.gif from base64'); }; | |
| }; | |
| // Set to base64 immediately to avoid 404 | |
| hpImg.src = window.HP_GIF_DATA_URI; | |
| console.log('hp.gif set to base64 source'); | |
| } | |
| if (wbImg && window.WHITEBOARD_PNG_DATA_URI) { | |
| // Try static first, but immediately fallback to base64 | |
| wbImg.onerror = function() { | |
| console.log('Loading whiteboardi.png from base64 fallback'); | |
| this.src = window.WHITEBOARD_PNG_DATA_URI; | |
| this.onload = function() { console.log('whiteboardi.png loaded successfully from base64'); }; | |
| this.onerror = function() { console.error('Failed to load whiteboardi.png from base64'); }; | |
| }; | |
| // Set to base64 immediately to avoid 404 | |
| wbImg.src = window.WHITEBOARD_PNG_DATA_URI; | |
| console.log('whiteboardi.png set to base64 source'); | |
| } | |
| } | |
| } | |
| })(); | |
| </script> | |
| <style> | |
| html, body { | |
| height: 100%; | |
| overflow: auto; | |
| margin: 0; | |
| padding: 0; | |
| } | |
| .yolov8-container { | |
| max-width: 1400px; | |
| margin: 0 auto; | |
| padding: 20px; | |
| min-height: 100vh; | |
| box-sizing: border-box; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .yolov8-container .main-content { | |
| display: grid ; | |
| grid-template-columns: 1fr 1.92fr 0.8fr ; | |
| gap: 20px ; | |
| margin-bottom: 30px ; | |
| margin-top: 0 ; | |
| min-height: auto ; | |
| padding: 0 ; | |
| overflow-y: visible ; | |
| flex-direction: row ; | |
| } | |
| .card { | |
| background: rgba(0, 0, 0, 0.8); | |
| border-radius: 20px; | |
| padding: 20px; | |
| box-shadow: 0 10px 20px rgba(0,0,0,0.3); | |
| border: 1px solid rgba(185, 29, 48, 0.3); | |
| transition: transform 0.3s ease, box-shadow 0.3s ease; | |
| min-height: 700px; | |
| max-height: calc(100vh - 40px); | |
| overflow-y: auto; | |
| box-sizing: border-box; | |
| } | |
| .card:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 15px 30px rgba(0,0,0,0.4); | |
| border-color: #B91D30; | |
| } | |
| .card h2 { | |
| color: #ffffff; | |
| margin-bottom: 8px; | |
| font-size: 1.4rem; | |
| font-weight: normal; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| flex-shrink: 0; | |
| } | |
| .upload-section { | |
| margin-bottom: 15px; | |
| flex-shrink: 0; | |
| } | |
| .upload-options { | |
| display: flex; | |
| gap: 10px; | |
| margin-bottom: 15px; | |
| } | |
| .upload-btn { | |
| flex: 1; | |
| padding: 15px; | |
| border: 2px dashed #555555; | |
| border-radius: 15px; | |
| text-align: center; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| background: rgba(0, 0, 0, 0.3); | |
| color: #ffffff; | |
| font-size: 1rem; | |
| } | |
| .upload-btn:hover { | |
| background: rgba(185, 29, 48, 0.1); | |
| color: white; | |
| border-color: #555555; | |
| } | |
| .upload-btn.active { | |
| background: rgba(0, 0, 0, 0.3); | |
| border-color: #555555; | |
| } | |
| .upload-btn i { | |
| margin-right: 8px; | |
| font-size: 1rem; | |
| } | |
| .file-input { | |
| display: none; | |
| } | |
| /* File Browser Modal */ | |
| .file-browser-modal { | |
| display: none; | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: rgba(0, 0, 0, 0.8); | |
| z-index: 10000; | |
| justify-content: center; | |
| align-items: center; | |
| } | |
| .file-browser-modal.active { | |
| display: flex; | |
| } | |
| .file-browser-content { | |
| background: rgba(0, 0, 0, 0.95); | |
| border: 2px solid #B91D30; | |
| border-radius: 20px; | |
| padding: 30px; | |
| max-width: 800px; | |
| max-height: 80vh; | |
| width: 90%; | |
| overflow-y: auto; | |
| box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); | |
| } | |
| .file-browser-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 20px; | |
| padding-bottom: 15px; | |
| border-bottom: 1px solid #333; | |
| } | |
| .file-browser-header h3 { | |
| color: #ffffff; | |
| margin: 0; | |
| font-size: 1.5rem; | |
| } | |
| .file-browser-close { | |
| background: none; | |
| border: none; | |
| color: #ffffff; | |
| font-size: 1.5rem; | |
| cursor: pointer; | |
| padding: 5px 10px; | |
| border-radius: 5px; | |
| transition: background 0.3s ease; | |
| } | |
| .file-browser-close:hover { | |
| background: rgba(185, 29, 48, 0.3); | |
| } | |
| .file-browser-list { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| margin-bottom: 20px; | |
| max-height: 400px; | |
| overflow-y: auto; | |
| } | |
| .file-browser-item { | |
| background: rgba(0, 0, 0, 0.6); | |
| border: 2px solid #333; | |
| border-radius: 8px; | |
| padding: 12px 15px; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| text-align: left; | |
| } | |
| .file-browser-item:hover { | |
| border-color: #B91D30; | |
| background: rgba(185, 29, 48, 0.1); | |
| transform: translateX(5px); | |
| } | |
| .file-browser-item.selected { | |
| border-color: #B91D30; | |
| background: rgba(185, 29, 48, 0.2); | |
| } | |
| .file-browser-item img { | |
| display: none; | |
| } | |
| .file-browser-item .file-name { | |
| color: #ffffff; | |
| font-size: 0.9rem; | |
| word-break: break-word; | |
| flex: 1; | |
| margin-right: 15px; | |
| } | |
| .file-browser-item .file-size { | |
| color: #999; | |
| font-size: 0.8rem; | |
| white-space: nowrap; | |
| } | |
| .file-browser-actions { | |
| display: flex; | |
| gap: 10px; | |
| justify-content: flex-end; | |
| margin-top: 20px; | |
| padding-top: 20px; | |
| border-top: 1px solid #333; | |
| } | |
| .file-browser-btn { | |
| padding: 10px 20px; | |
| border: none; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| font-size: 1rem; | |
| transition: all 0.3s ease; | |
| } | |
| .file-browser-btn-primary { | |
| background: #B91D30; | |
| color: white; | |
| } | |
| .file-browser-btn-primary:hover { | |
| background: #8a1523; | |
| } | |
| .file-browser-btn-secondary { | |
| background: #333; | |
| color: white; | |
| } | |
| .file-browser-btn-secondary:hover { | |
| background: #555; | |
| } | |
| .file-browser-loading { | |
| text-align: center; | |
| color: #ffffff; | |
| padding: 40px; | |
| } | |
| .file-browser-empty { | |
| text-align: center; | |
| color: #999; | |
| padding: 40px; | |
| } | |
| /* Model selection div (#imageControls) should be visible, but other image-controls hidden */ | |
| .image-controls:not(#imageControls) { | |
| margin-bottom: 25px; | |
| display: none ; | |
| } | |
| /* Ensure model selection is visible - override inline styles */ | |
| #imageControls { | |
| margin-bottom: 25px ; | |
| display: block ; | |
| visibility: visible ; | |
| opacity: 1 ; | |
| height: auto ; | |
| overflow: visible ; | |
| } | |
| /* Additional class-based override for when JavaScript removes inline style */ | |
| #imageControls.model-selection-visible { | |
| display: block ; | |
| visibility: visible ; | |
| opacity: 1 ; | |
| } | |
| .control-group { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 15px; | |
| margin-bottom: 15px; | |
| } | |
| .control-group.full-width { | |
| grid-template-columns: 1fr; | |
| } | |
| .control-label { | |
| color: #ffffff; | |
| font-size: 0.9rem; | |
| margin-bottom: 5px; | |
| font-weight: 500; | |
| white-space: nowrap; | |
| } | |
| .model-select { | |
| width: 100%; | |
| padding: 12px; | |
| border: 1px solid #3C3435; | |
| border-radius: 8px; | |
| background: rgba(0, 0, 0, 0.5); | |
| color: #ffffff; | |
| font-size: 0.9rem; | |
| } | |
| .model-select:focus { | |
| outline: none; | |
| border-color: #ffffff; | |
| box-shadow: 0 0 0 2px rgba(185, 29, 48, 0.3); | |
| } | |
| .model-select option { | |
| background: #000000; | |
| color: #ffffff; | |
| } | |
| .control-item { | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .control-item label { | |
| margin-bottom: 5px; | |
| font-weight: normal; | |
| color: #ffffff; | |
| font-size: 0.9rem; | |
| } | |
| .control-item input { | |
| padding: 12px; | |
| border: 2px solid #333333; | |
| border-radius: 8px; | |
| font-size: 0.9rem; | |
| background: rgba(0, 0, 0, 0.6); | |
| color: #ffffff; | |
| transition: border-color 0.3s ease; | |
| } | |
| .control-item input:focus { | |
| outline: none; | |
| border-color: #B91D30; | |
| } | |
| /* Detection Results Container - Match Architecture Details Width */ | |
| #detectionResultsContainer { | |
| width: 100%; | |
| max-width: none; | |
| margin-bottom: 10px; | |
| } | |
| .preview-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 10px; | |
| } | |
| .preview-header h4 { | |
| color: #ffffff; | |
| margin: 0; | |
| font-size: 1rem; | |
| font-weight: normal; | |
| } | |
| .reset-crop-btn { | |
| padding: 6px 12px; | |
| background: #B91D30; | |
| color: white; | |
| border: none; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| font-size: 0.8rem; | |
| transition: background 0.3s ease; | |
| } | |
| .reset-crop-btn:hover { | |
| background: #CC0000; | |
| } | |
| .image-preview-wrapper { | |
| position: relative; | |
| display: inline-block; | |
| border: none; | |
| border-radius: 10px; | |
| overflow: hidden; | |
| margin-bottom: 10px; | |
| max-width: 100%; | |
| max-height: 300px; | |
| } | |
| .image-preview-canvas { | |
| max-width: 100%; | |
| max-height: 300px; | |
| cursor: crosshair; | |
| display: block; | |
| image-rendering: auto; | |
| image-rendering: smooth; | |
| user-select: none; | |
| -webkit-user-select: none; | |
| -moz-user-select: none; | |
| -ms-user-select: none; | |
| pointer-events: auto; | |
| } | |
| #resultImage { | |
| width: 100%; | |
| height: auto; | |
| max-height: 300px; | |
| border-radius: 8px; | |
| object-fit: contain; | |
| display: block; | |
| margin: 0 auto; | |
| } | |
| .crop-overlay { | |
| position: absolute; | |
| border: 2px dashed #B91D30; | |
| background: rgba(185, 29, 48, 0.1); | |
| pointer-events: none; | |
| display: none; | |
| } | |
| .crop-instructions { | |
| margin-top: 10px; | |
| padding: 8px; | |
| background: rgba(0, 0, 0, 0.3); | |
| border-radius: 6px; | |
| font-size: 0.8rem; | |
| color: #cccccc; | |
| text-align: center; | |
| } | |
| .crop-instructions i { | |
| margin-right: 5px; | |
| color: #B91D30; | |
| } | |
| .transform-buttons { | |
| display: flex; | |
| gap: 12px; | |
| margin-bottom: 20px; | |
| flex-wrap: wrap; | |
| } | |
| .transform-btn-small { | |
| padding: 10px 12px; | |
| background: rgba(0, 0, 0, 0.8); | |
| border: 2px solid #333333; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| font-size: 0.8rem; | |
| color: #ffffff; | |
| min-width: 40px; | |
| min-height: 40px; | |
| } | |
| .transform-btn-small .btn-text { | |
| color: #ffffff ; | |
| font-size: 1.2rem ; | |
| display: inline-block ; | |
| font-weight: bold ; | |
| text-align: center ; | |
| vertical-align: middle ; | |
| } | |
| .transform-btn-small { | |
| display: flex ; | |
| align-items: center ; | |
| justify-content: center ; | |
| } | |
| .transform-btn-small:hover { | |
| background: rgba(185, 29, 48, 0.3); | |
| border-color: #ffffff; | |
| } | |
| .transform-btn-small:hover i { | |
| color: #ffffff ; | |
| transform: scale(1.1); | |
| } | |
| .transform-btn-small:active { | |
| transform: translateY(1px); | |
| } | |
| /* Compact Controls Styling */ | |
| .compact-controls { | |
| margin-top: 15px; | |
| padding: 15px; | |
| background: rgba(0, 0, 0, 0.6); | |
| border-radius: 15px; | |
| border: 2px solid #333333; | |
| } | |
| .control-row { | |
| display: flex; | |
| gap: 10px; | |
| margin-bottom: 15px; | |
| flex-wrap: wrap; | |
| } | |
| .control-group-compact { | |
| display: flex; | |
| flex-direction: column; | |
| min-width: 60px; | |
| } | |
| .control-group-compact label { | |
| font-size: 0.8rem; | |
| font-weight: 600; | |
| color: #ffffff; | |
| margin-bottom: 3px; | |
| } | |
| .control-group-compact input { | |
| padding: 6px 8px; | |
| border: 1px solid #333333; | |
| border-radius: 6px; | |
| font-size: 0.8rem; | |
| width: 60px; | |
| background: rgba(0, 0, 0, 0.6); | |
| color: #ffffff; | |
| transition: border-color 0.3s ease; | |
| } | |
| .control-group-compact input:focus { | |
| outline: none; | |
| border-color: #B91D30; | |
| } | |
| .transform-buttons-compact { | |
| display: flex ; | |
| gap: 8px; | |
| margin-bottom: 10px; | |
| justify-content: center; | |
| visibility: visible ; | |
| opacity: 1 ; | |
| } | |
| .status-display { | |
| display: flex; | |
| gap: 15px; | |
| justify-content: center; | |
| font-size: 0.8rem; | |
| color: #cccccc; | |
| } | |
| .detect-btn { | |
| width: 100%; | |
| padding: 16px; | |
| background: #555555; | |
| color: white; | |
| border: none; | |
| border-radius: 12px; | |
| cursor: pointer; | |
| font-size: 1.1rem; | |
| font-weight: bold; | |
| transition: all 0.3s ease; | |
| margin-top: 15px; | |
| opacity: 0.9; | |
| } | |
| .detect-btn:hover { | |
| background: #666666; | |
| transform: translateY(-2px); | |
| opacity: 0.95; | |
| } | |
| .detect-btn:disabled { | |
| opacity: 0.6; | |
| cursor: not-allowed; | |
| background: rgba(0, 0, 0, 0.3); | |
| color: #888888; | |
| } | |
| .performance-stats { | |
| display: grid; | |
| grid-template-columns: 1fr; | |
| gap: 3px; | |
| margin-bottom: 6px; | |
| } | |
| .stat-card { | |
| background: rgba(0, 0, 0, 0.6); | |
| padding: 5px 8px; | |
| border-radius: 10px; | |
| text-align: left; | |
| border: none; | |
| } | |
| .stat-value { | |
| font-size: 0.8rem; | |
| font-weight: normal; | |
| color: #cccccc; /* match stat-label color */ | |
| margin: 0; | |
| line-height: 1.1; | |
| } | |
| .stat-label { | |
| color: #cccccc; | |
| font-size: 0.8rem; | |
| font-weight: normal; | |
| margin: 0 0 2px 0; | |
| line-height: 1.1; | |
| } | |
| .architecture-details { | |
| background: rgba(0, 0, 0, 0.6); | |
| padding: 10px; | |
| border-radius: 20px; | |
| border: none; | |
| flex: 1; | |
| min-height: 0; | |
| display: flex; | |
| flex-direction: column; | |
| overflow-y: auto; | |
| max-height: 100%; | |
| flex-grow: 1; | |
| } | |
| .architecture-details h3 { | |
| color: #ffffff; | |
| margin-bottom: 10px; | |
| font-size: 1.2rem; | |
| font-weight: normal; | |
| } | |
| .detail-grid { | |
| display: grid; | |
| grid-template-columns: 1fr; | |
| gap: 8px; | |
| } | |
| .detail-item { | |
| text-align: left; | |
| } | |
| .detail-label { | |
| color: #ffffff; | |
| font-size: 0.85rem; | |
| margin-bottom: 2px; | |
| font-weight: normal; | |
| } | |
| .detail-value { | |
| color: #ffffff; | |
| font-weight: normal; | |
| font-size: 0.95rem; | |
| } | |
| .loading { | |
| display: none; | |
| text-align: center; | |
| padding: 20px; | |
| } | |
| .spinner { | |
| border: 4px solid #f3f3f3; | |
| border-top: 4px solid #B91D30; | |
| border-radius: 50%; | |
| width: 40px; | |
| height: 40px; | |
| animation: spin 1s linear infinite; | |
| margin: 0 auto 10px; | |
| } | |
| @keyframes spin { | |
| 0% { transform: rotate(0deg); } | |
| 100% { transform: rotate(360deg); } | |
| } | |
| .error-message, .success-message { | |
| padding: 15px; | |
| margin: 10px 0; | |
| border-radius: 8px; | |
| font-weight: bold; | |
| text-align: center; | |
| } | |
| .error-message { | |
| background: rgba(220, 53, 69, 0.2); | |
| border: 2px solid #dc3545; | |
| color: #dc3545; | |
| } | |
| .success-message { | |
| background: rgba(40, 167, 69, 0.2); | |
| border: 2px solid #28a745; | |
| color: #28a745; | |
| } | |
| .batch-navigation { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| margin-bottom: 15px; | |
| padding: 10px; | |
| background: rgba(0, 0, 0, 0.3); | |
| border-radius: 8px; | |
| } | |
| .batch-info { | |
| flex: 1; | |
| color: #ffffff; | |
| font-size: 0.9rem; | |
| } | |
| .batch-counter { | |
| font-weight: bold; | |
| color: #B91D30; | |
| } | |
| .batch-btn { | |
| padding: 6px 12px; | |
| background: rgba(185, 29, 48, 0.2); | |
| color: #ffffff; | |
| border: 1px solid #B91D30; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| font-size: 0.8rem; | |
| transition: all 0.3s ease; | |
| } | |
| .batch-btn:hover { | |
| background: rgba(185, 29, 48, 0.3); | |
| } | |
| .batch-btn:disabled { | |
| opacity: 0.5; | |
| cursor: not-allowed; | |
| } | |
| @media (max-width: 768px) { | |
| .yolov8-container .main-content { | |
| grid-template-columns: 1fr ; | |
| } | |
| .control-group { | |
| grid-template-columns: 1fr; | |
| } | |
| .performance-stats { | |
| grid-template-columns: repeat(2, 1fr); | |
| } | |
| .upload-options { | |
| flex-direction: column; | |
| } | |
| .control-row { | |
| justify-content: center; | |
| } | |
| .control-group-compact { | |
| min-width: 50px; | |
| } | |
| .control-group-compact input { | |
| width: 50px; | |
| } | |
| .transform-buttons-compact { | |
| flex-wrap: wrap; | |
| justify-content: center; | |
| } | |
| .status-display { | |
| flex-direction: column; | |
| gap: 5px; | |
| } | |
| } | |
| .performance-section { | |
| background: rgba(0, 0, 0, 0.6); | |
| border-radius: 15px; | |
| padding: 15px; | |
| border: 1px solid rgba(185, 29, 48, 0.2); | |
| margin-top: 20px; | |
| } | |
| .performance-grid { | |
| display: grid; | |
| grid-template-columns: 1fr; | |
| gap: 15px; | |
| } | |
| .performance-item { | |
| background: rgba(0, 0, 0, 0.3); | |
| padding: 12px; | |
| border-radius: 8px; | |
| border: 1px solid rgba(185, 29, 48, 0.1); | |
| } | |
| .performance-label { | |
| color: #ccc; | |
| font-size: 0.8rem; | |
| margin-bottom: 5px; | |
| } | |
| .performance-value { | |
| color: #ffffff; | |
| font-size: 1rem; | |
| font-weight: 600; | |
| } | |
| .metrics-section { | |
| background: rgba(0, 0, 0, 0.6); | |
| border-radius: 15px; | |
| padding: 10px; | |
| margin-top: 10px; | |
| border: 1px solid rgba(185, 29, 48, 0.2); | |
| } | |
| .metrics-grid { | |
| display: grid; | |
| grid-template-columns: 1fr; | |
| gap: 6px; | |
| } | |
| .metric-item { | |
| background: rgba(0, 0, 0, 0.3); | |
| padding: 6px; | |
| border-radius: 8px; | |
| border: 1px solid rgba(185, 29, 48, 0.1); | |
| } | |
| .metric-label { | |
| color: #ccc; | |
| font-size: 0.8rem; | |
| margin-bottom: 3px; | |
| } | |
| .metric-value { | |
| color: #ffffff; | |
| font-size: 0.95rem; | |
| font-weight: 600; | |
| } | |
| .detection-results { | |
| background: rgba(0, 0, 0, 0.6); | |
| border-radius: 15px; | |
| padding: 20px; | |
| margin-top: 20px; | |
| border: 1px solid rgba(185, 29, 48, 0.2); | |
| } | |
| .detection-title { | |
| color: #ffffff; | |
| font-size: 1.1rem; | |
| margin-bottom: 15px; | |
| font-weight: 600; | |
| } | |
| .prediction-item { | |
| background: rgba(0, 0, 0, 0.3); | |
| padding: 12px; | |
| border-radius: 8px; | |
| margin-bottom: 10px; | |
| border: 1px solid rgba(185, 29, 48, 0.1); | |
| color: #ffffff; | |
| font-size: 0.9rem; | |
| } | |
| .detection-results-dual { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 15px; | |
| margin-bottom: 10px; | |
| } | |
| .detection-results-column { | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .detection-subtitle { | |
| font-size: 1rem; | |
| font-weight: 600; | |
| color: #cccccc; | |
| margin-bottom: 10px; | |
| text-align: left; | |
| } | |
| .detection-results-column .prediction-item { | |
| margin-bottom: 5px; | |
| flex: 1; | |
| } | |
| .prediction-item:last-child { | |
| margin-bottom: 0; | |
| } | |
| .result-image { | |
| width: 100%; | |
| max-height: 200px; | |
| object-fit: contain; | |
| border-radius: 8px; | |
| margin-top: 15px; | |
| border: 1px solid rgba(185, 29, 48, 0.2); | |
| } | |
| .top-sections { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 15px; | |
| margin-bottom: 20px; | |
| } | |
| .filename-display { | |
| padding: 10px; | |
| background: rgba(255, 255, 255, 0.05); | |
| border-radius: 8px; | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| } | |
| .filename-label { | |
| font-size: 0.8rem; | |
| color: #888; | |
| margin-bottom: 5px; | |
| } | |
| .filename-value { | |
| font-size: 0.9rem; | |
| color: #fff; | |
| font-weight: 500; | |
| word-break: break-all; | |
| } | |
| </style> | |
| <div class="yolov8-container"> | |
| <div id="errorMessage" class="error-message" style="display: none;"></div> | |
| <div id="successMessage" class="success-message" style="display: none;"></div> | |
| <div class="main-content"> | |
| <div class="card"> | |
| <h2> | |
| Spatiotemporal Detection | |
| </h2> | |
| <div class="upload-section"> | |
| <div class="upload-options"> | |
| <div class="upload-btn active" id="singleUploadBtn"> | |
| Single Image | |
| </div> | |
| <div class="upload-btn" id="batchUploadBtn"> | |
| Batch Upload | |
| </div> | |
| </div> | |
| <input type="file" id="fileInput" class="file-input" accept="image/*,.sto" multiple> | |
| <input type="file" id="batchFileInput" class="file-input" accept="image/*,.sto" multiple> | |
| <!-- Image Preview and Crop Area --> | |
| <div class="image-preview-container" id="imagePreviewContainer" style="display: none;"> | |
| <div class="preview-header"> | |
| <h4>Image Preview & Crop</h4> | |
| </div> | |
| <div class="image-preview-wrapper"> | |
| <canvas id="imagePreview" class="image-preview-canvas"></canvas> | |
| <div class="crop-overlay" id="cropOverlay"></div> | |
| </div> | |
| <div class="crop-instructions"> | |
| <i class="fas fa-info-circle"></i> | |
| Drag to select crop area. Detection will analyze the selected region. | |
| </div> | |
| <!-- Batch Navigation Controls (hidden until batch upload) --> | |
| <div class="batch-navigation" id="batchNavigation" style="display: none;"> | |
| <div class="batch-info"> | |
| <span class="batch-counter" id="batchCounter">1 of 1</span> | |
| </div> | |
| <button class="batch-btn" id="prevImageBtn" disabled>Previous</button> | |
| <button class="batch-btn" id="nextImageBtn" disabled>Next</button> | |
| </div> | |
| <!-- Compact Controls Row --> | |
| <div class="compact-controls"> | |
| <div class="transform-buttons-compact"> | |
| <button type="button" class="transform-btn-small" onclick="rotateImage(90)" title="Rotate 90°"> | |
| <span class="btn-text">↻</span> | |
| </button> | |
| <button type="button" class="transform-btn-small" onclick="rotateImage(-90)" title="Rotate -90°"> | |
| <span class="btn-text">↺</span> | |
| </button> | |
| <button type="button" class="transform-btn-small" onclick="flipImage('horizontal')" title="Flip Horizontal"> | |
| <span class="btn-text">↔</span> | |
| </button> | |
| <button type="button" class="transform-btn-small" onclick="flipImage('vertical')" title="Flip Vertical"> | |
| <span class="btn-text">↕</span> | |
| </button> | |
| <button type="button" class="transform-btn-small reset-btn" onclick="resetCrop()" title="Reset Crop"> | |
| <span class="btn-text">✕</span> | |
| </button> | |
| </div> | |
| <div class="status-display"> | |
| <span>Rotation: <span id="rotationAngle">0°</span></span> | |
| <span>Flip: <span id="flipStatus">None</span></span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Model Selection - Hidden until image upload --> | |
| <div class="image-controls" id="imageControls" style="display: none;" data-initially-hidden="true"> | |
| <div class="control-group"> | |
| <div> | |
| <div class="control-label">Spatial detection head:</div> | |
| <select id="spatialHeadSelect" class="model-select"> | |
| <option value="">Select spatial head...</option> | |
| <option value="dinov3_custom">DINOv3_custom</option> | |
| <option value="yolov8_custom">YOLOv8_custom</option> | |
| <option value="yolov3_custom">YOLOv3_custom</option> | |
| </select> | |
| </div> | |
| <div> | |
| <div class="control-label">Material detection head:</div> | |
| <select id="materialHeadSelect" class="model-select"> | |
| <option value="">Select material head...</option> | |
| <option value="custom_material_classifier">Custom material classifier</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div class="control-group"> | |
| <div id="spatialWeightGroup" style="display: none;"> | |
| <div class="control-label" id="spatialWeightLabel">Object classifier weight:</div> | |
| <select id="spatialWeightSelect" class="model-select"> | |
| <option value="">Loading weights...</option> | |
| </select> | |
| </div> | |
| <div id="materialWeightGroup" style="display: none;"> | |
| <div class="control-label" id="materialWeightLabel">Material classifier weight:</div> | |
| <select id="materialWeightSelect" class="model-select"> | |
| <option value="">Loading weights...</option> | |
| </select> | |
| </div> | |
| </div> | |
| <!-- Hidden original select preserved for backend compatibility --> | |
| <select id="modelSelect" style="display:none"> | |
| <option value=""></option> | |
| </select> | |
| </div> | |
| <!-- Detect Button --> | |
| <button type="button" class="detect-btn" id="detectBtn" disabled> | |
| Scene Detection | |
| </button> | |
| <!-- Loading Indicator --> | |
| <div class="loading" id="loading"> | |
| <div class="spinner"></div> | |
| <span>Processing...</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Right Card: Results --> | |
| <div class="card"> | |
| <h2> | |
| Detection Results | |
| </h2> | |
| <div class="top-sections"> | |
| <div class="filename-display"> | |
| <div class="filename-label">Selected Image:</div> | |
| <div class="filename-value" id="filenameValue">No image selected</div> | |
| </div> | |
| </div> | |
| <div class="detection-results" id="detectionResultsContainer" style="display: none;"> | |
| <div class="detection-title">Input used for inference</div> | |
| <div style="display: flex; gap: 20px; align-items: flex-start;"> | |
| <img id="finalInputPreview" class="result-image" alt="Final input preview" style="display:none;" /> | |
| <div id="hpWhiteboardContainer" style="display: none; position: relative; align-items: flex-start; gap: 0px;"> | |
| <img alt="HP" id="hpGifImage" style="max-width: 120px; height: auto; margin-top: 4px; margin-right: 0px;" /> | |
| <div style="position: relative;"> | |
| <img alt="Whiteboard" id="whiteboardImage" style="max-width: 200px; height: auto; margin-left: 0;" /> | |
| <div id="whiteboardCaption" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: white; font-size: 10px; text-align: center; width: 80%; white-space: normal; word-wrap: break-word; display: none;">-</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="detection-results-dual"> | |
| <div class="detection-results-column"> | |
| <div class="detection-subtitle">Detected object(s)</div> | |
| <div id="prediction4" class="prediction-item">1. -</div> | |
| <div id="prediction5" class="prediction-item">2. -</div> | |
| <div id="prediction6" class="prediction-item">3. -</div> | |
| <div id="detectionCountObjects" class="prediction-item"></div> | |
| </div> | |
| <div class="detection-results-column"> | |
| <div class="detection-subtitle">Detected material(s)</div> | |
| <div id="prediction1" class="prediction-item">1. -</div> | |
| <div id="prediction2" class="prediction-item">2. -</div> | |
| <div id="prediction3" class="prediction-item">3. -</div> | |
| <div id="detectionCountMaterials" class="prediction-item"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Input and model metrics Card --> | |
| <div class="card"> | |
| <h2> | |
| Input and model metrics | |
| </h2> | |
| <div class="metrics-section"> | |
| <div class="metrics-grid"> | |
| <div class="metric-item"> | |
| <div class="metric-label">File Size</div> | |
| <div class="metric-value" id="fileSizeValue">-</div> | |
| </div> | |
| <div class="metric-item"> | |
| <div class="metric-label">2D intensity image Dimensions</div> | |
| <div class="metric-value" id="intensityImageDimensions">-</div> | |
| </div> | |
| <div class="metric-item"> | |
| <div class="metric-label">transient image Dimensions</div> | |
| <div class="metric-value" id="transientImageDimensions">-</div> | |
| </div> | |
| <div class="metric-item"> | |
| <div class="metric-label">Upload Time</div> | |
| <div class="metric-value" id="uploadTimeValue">-</div> | |
| </div> | |
| <div class="metric-item"> | |
| <div class="metric-label">Processing Status</div> | |
| <div class="metric-value" id="processingStatusValue"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Spatial Detection Performance --> | |
| <div class="performance-section"> | |
| <div class="performance-grid"> | |
| <div class="performance-item"> | |
| <div class="performance-label">spatial detection architecture</div> | |
| <div class="performance-value" id="architectureValue">-</div> | |
| </div> | |
| <div class="performance-item"> | |
| <div class="performance-label">spatial detection inference time</div> | |
| <div class="performance-value" id="inferenceTimeValue">-</div> | |
| </div> | |
| <div class="performance-item"> | |
| <div class="performance-label">material detection architecture</div> | |
| <div class="performance-value" id="materialArchitectureValue">-</div> | |
| </div> | |
| <div class="performance-item"> | |
| <div class="performance-label">material detection inference time</div> | |
| <div class="performance-value" id="materialInferenceTimeValue">-</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- File Browser Modal --> | |
| <div class="file-browser-modal" id="fileBrowserModal"> | |
| <div class="file-browser-content"> | |
| <div class="file-browser-header"> | |
| <h3>Select Test Image</h3> | |
| <button class="file-browser-close" id="fileBrowserClose">×</button> | |
| </div> | |
| <div id="fileBrowserList" class="file-browser-list"> | |
| <div class="file-browser-loading">Loading images...</div> | |
| </div> | |
| <div class="file-browser-actions"> | |
| <button class="file-browser-btn file-browser-btn-secondary" id="fileBrowserCancel">Cancel</button> | |
| <button class="file-browser-btn file-browser-btn-primary" id="fileBrowserSelect" disabled>Select</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {% endblock %} | |
| {% block scripts %} | |
| <script type="text/javascript"> | |
| // Helper function to extract basename from a filename (handles paths with /, \, or :) | |
| function getBasename(filename) { | |
| if (!filename) return ''; | |
| // Handle colon-separated paths (Mac format) and normal paths | |
| return filename.split(':').pop().split('/').pop().split('\\').pop(); | |
| } | |
| // Initialize variables | |
| let currentUploadMode = 'single'; | |
| let selectedFile = null; | |
| let originalStoFile = null; // Store original .sto file for inference | |
| let stoIndex0 = null; // Store index 0 from .sto file | |
| let stoIndex1 = null; // Store index 1 from .sto file | |
| let currentImage = null; | |
| let currentRotation = 0; | |
| let currentFlip = { | |
| horizontal: false, | |
| vertical: false | |
| }; | |
| let batchFiles = []; | |
| let currentBatchIndex = 0; | |
| let isDragging = false; | |
| let cropStartX = 0; | |
| let cropStartY = 0; | |
| // Initialize when DOM is loaded | |
| document.addEventListener('DOMContentLoaded', function() { | |
| setupEventListeners(); | |
| setupCropHandlers(); | |
| setupBatchNavigation(); | |
| }); | |
| function setupEventListeners() { | |
| const singleUploadBtn = document.getElementById('singleUploadBtn'); | |
| const batchUploadBtn = document.getElementById('batchUploadBtn'); | |
| const fileInput = document.getElementById('fileInput'); | |
| const batchFileInput = document.getElementById('batchFileInput'); | |
| // File Browser Modal | |
| const fileBrowserModal = document.getElementById('fileBrowserModal'); | |
| const fileBrowserClose = document.getElementById('fileBrowserClose'); | |
| const fileBrowserCancel = document.getElementById('fileBrowserCancel'); | |
| const fileBrowserSelect = document.getElementById('fileBrowserSelect'); | |
| const fileBrowserList = document.getElementById('fileBrowserList'); | |
| let selectedFiles = []; | |
| let isBatchMode = false; | |
| // Upload buttons - open file browser modal | |
| singleUploadBtn.addEventListener('click', () => { | |
| currentUploadMode = 'single'; | |
| singleUploadBtn.classList.add('active'); | |
| batchUploadBtn.classList.remove('active'); | |
| isBatchMode = false; | |
| selectedFiles = []; | |
| openFileBrowser(); | |
| }); | |
| batchUploadBtn.addEventListener('click', () => { | |
| currentUploadMode = 'batch'; | |
| batchUploadBtn.classList.add('active'); | |
| singleUploadBtn.classList.remove('active'); | |
| isBatchMode = true; | |
| selectedFiles = []; | |
| openFileBrowser(); | |
| }); | |
| // Close modal handlers | |
| fileBrowserClose.addEventListener('click', closeFileBrowser); | |
| fileBrowserCancel.addEventListener('click', closeFileBrowser); | |
| fileBrowserModal.addEventListener('click', (e) => { | |
| if (e.target === fileBrowserModal) { | |
| closeFileBrowser(); | |
| } | |
| }); | |
| // Select button handler | |
| fileBrowserSelect.addEventListener('click', () => { | |
| if (selectedFiles.length === 0) return; | |
| if (isBatchMode) { | |
| loadFilesFromBrowser(selectedFiles); | |
| } else { | |
| loadFilesFromBrowser([selectedFiles[0]]); | |
| } | |
| closeFileBrowser(); | |
| }); | |
| // File browser functions | |
| async function openFileBrowser() { | |
| fileBrowserModal.classList.add('active'); | |
| fileBrowserList.innerHTML = '<div class="file-browser-loading">Loading images...</div>'; | |
| fileBrowserSelect.disabled = true; | |
| selectedFiles = []; | |
| try { | |
| const response = await fetch('/api/list_testimages/spatiotemporal_detection'); | |
| const data = await response.json(); | |
| // Display dataset repo link if available | |
| if (data.repo_url) { | |
| const repoLinkDiv = document.createElement('div'); | |
| repoLinkDiv.className = 'dataset-repo-link'; | |
| repoLinkDiv.style.cssText = 'padding: 10px; margin-bottom: 10px; background: rgba(0, 206, 209, 0.1); border-radius: 5px; border: 1px solid rgba(0, 206, 209, 0.3);'; | |
| repoLinkDiv.innerHTML = `<a href="${data.repo_url}" target="_blank" style="color: #00CED1; text-decoration: none; font-size: 0.9em;"><i class="fas fa-external-link-alt"></i> View Dataset on Hugging Face: ${data.repo_id || 'Dataset Repository'}</a>`; | |
| fileBrowserList.appendChild(repoLinkDiv); | |
| } | |
| if (data.success && (data.files || data.items || []).length > 0) { | |
| const files = data.files || data.items || []; | |
| files.forEach(file => { | |
| const fileItem = document.createElement('div'); | |
| fileItem.className = 'file-browser-item'; | |
| fileItem.dataset.filename = file.name; | |
| const fileSize = formatFileSize(file.size); | |
| fileItem.innerHTML = ` | |
| <div class="file-name">${file.name}</div> | |
| <div class="file-size">${fileSize}</div> | |
| `; | |
| // Single click - select in batch mode, import directly in single mode | |
| fileItem.addEventListener('click', () => { | |
| if (isBatchMode) { | |
| if (fileItem.classList.contains('selected')) { | |
| fileItem.classList.remove('selected'); | |
| selectedFiles = selectedFiles.filter(f => f !== file.name); | |
| } else { | |
| fileItem.classList.add('selected'); | |
| selectedFiles.push(file.name); | |
| } | |
| fileBrowserSelect.disabled = selectedFiles.length === 0; | |
| } else { | |
| document.querySelectorAll('.file-browser-item').forEach(item => { | |
| item.classList.remove('selected'); | |
| }); | |
| fileItem.classList.add('selected'); | |
| selectedFiles = [file.name]; | |
| fileBrowserSelect.disabled = false; | |
| loadFilesFromBrowser([file.name]); | |
| closeFileBrowser(); | |
| } | |
| }); | |
| // Double click - always import directly | |
| fileItem.addEventListener('dblclick', () => { | |
| loadFilesFromBrowser([file.name]); | |
| closeFileBrowser(); | |
| }); | |
| fileBrowserList.appendChild(fileItem); | |
| }); | |
| } else { | |
| fileBrowserList.innerHTML = '<div class="file-browser-empty">No test images found in directory</div>'; | |
| } | |
| } catch (error) { | |
| console.error('Error loading test images:', error); | |
| fileBrowserList.innerHTML = '<div class="file-browser-empty">Error loading images. Please try uploading from your device.</div>'; | |
| } | |
| } | |
| function closeFileBrowser() { | |
| fileBrowserModal.classList.remove('active'); | |
| selectedFiles = []; | |
| fileBrowserSelect.disabled = true; | |
| } | |
| async function loadFilesFromBrowser(filenames) { | |
| try { | |
| if (isBatchMode) { | |
| const files = []; | |
| for (const filename of filenames) { | |
| const response = await fetch(`/api/get_testimage/spatiotemporal_detection/${encodeURIComponent(filename)}`); | |
| if (!response.ok) { | |
| throw new Error(`Failed to load ${filename}: ${response.statusText}`); | |
| } | |
| const blob = await response.blob(); | |
| const ext = filename.split('.').pop(); | |
| const mimeTypes = { | |
| 'jpg': 'image/jpeg', 'JPG': 'image/jpeg', | |
| 'jpeg': 'image/jpeg', 'JPEG': 'image/jpeg', | |
| 'png': 'image/png', 'PNG': 'image/png', | |
| 'gif': 'image/gif', 'GIF': 'image/gif', | |
| 'webp': 'image/webp', 'WEBP': 'image/webp', | |
| 'bmp': 'image/bmp', 'BMP': 'image/bmp', | |
| 'tiff': 'image/tiff', 'TIFF': 'image/tiff', | |
| 'tif': 'image/tiff', 'TIF': 'image/tiff', | |
| 'heic': 'image/heic', 'HEIC': 'image/heic', | |
| 'sto': 'application/octet-stream', 'STO': 'application/octet-stream' | |
| }; | |
| const mimeType = mimeTypes[ext] || mimeTypes[ext.toLowerCase()] || blob.type || 'image/jpeg'; | |
| // Extract just the filename (basename) in case filename contains a path | |
| const basename = filename.split('/').pop().split('\\').pop(); | |
| const file = new File([blob], basename, { type: mimeType, lastModified: Date.now() }); | |
| files.push(file); | |
| } | |
| const dataTransfer = new DataTransfer(); | |
| files.forEach(file => dataTransfer.items.add(file)); | |
| // Clear the input first, then set files | |
| batchFileInput.value = ''; | |
| batchFileInput.files = dataTransfer.files; | |
| // Trigger change event to process the files and display preview | |
| const changeEvent = new Event('change', { bubbles: true }); | |
| batchFileInput.dispatchEvent(changeEvent); | |
| } else { | |
| const filename = filenames[0]; | |
| const lower = filename.toLowerCase(); | |
| // Check if it's a .sto file | |
| if (lower.endsWith('.sto')) { | |
| // For .sto files, we need to extract both indices | |
| const response = await fetch(`/api/get_testimage/spatiotemporal_detection/${encodeURIComponent(filename)}`); | |
| if (!response.ok) { | |
| throw new Error(`Failed to load ${filename}: ${response.statusText}`); | |
| } | |
| const blob = await response.blob(); | |
| // Extract just the filename (basename) in case filename contains a path | |
| const basename = getBasename(filename); | |
| const stoFile = new File([blob], basename, { type: 'application/octet-stream', lastModified: Date.now() }); | |
| // Store original .sto file | |
| originalStoFile = stoFile; | |
| // Set filename immediately to show the .sto filename (use basename only) | |
| const filenameEl = document.getElementById('filenameValue'); | |
| if (filenameEl) { | |
| filenameEl.textContent = basename; | |
| } | |
| // Extract both indices from .sto file | |
| const formData0 = new FormData(); | |
| formData0.append('file', stoFile); | |
| const formData1 = new FormData(); | |
| formData1.append('file', stoFile); | |
| Promise.all([ | |
| fetch('/api/extract_sto_index0', { method: 'POST', body: formData0 }) | |
| .then(r => r.json()), | |
| fetch('/api/extract_sto_index1', { method: 'POST', body: formData1 }) | |
| .then(r => r.json()) | |
| ]) | |
| .then(([data0, data1]) => { | |
| // Store index 0 | |
| if (data0 && data0.success && data0.image) { | |
| let imageDataUrl0 = data0.image; | |
| if (!imageDataUrl0.startsWith('data:')) { | |
| imageDataUrl0 = 'data:image/png;base64,' + imageDataUrl0; | |
| } | |
| stoIndex0 = imageDataUrl0; | |
| } | |
| // Extract and display index 1 for preview | |
| if (data1 && data1.success && data1.image) { | |
| let imageDataUrl1 = data1.image; | |
| if (!imageDataUrl1.startsWith('data:')) { | |
| imageDataUrl1 = 'data:image/png;base64,' + imageDataUrl1; | |
| } | |
| stoIndex1 = imageDataUrl1; | |
| // Update dimensions from .sto indices | |
| updateStoImageDimensions(); | |
| fetch(imageDataUrl1) | |
| .then(res => res.blob()) | |
| .then(extractedBlob => { | |
| // Extract just the filename (basename) in case filename contains a path | |
| const basename = getBasename(filename); | |
| const extractedFile = new File([extractedBlob], basename.replace('.sto', '.png'), { type: 'image/png', lastModified: Date.now() }); | |
| selectedFile = extractedFile; | |
| // Ensure filename is set to original .sto filename (use basename only) | |
| const filenameEl = document.getElementById('filenameValue'); | |
| if (filenameEl && originalStoFile) { | |
| filenameEl.textContent = getBasename(originalStoFile.name); | |
| } | |
| const dataTransfer = new DataTransfer(); | |
| dataTransfer.items.add(extractedFile); | |
| fileInput.value = ''; | |
| fileInput.files = dataTransfer.files; | |
| const changeEvent = new Event('change', { bubbles: true }); | |
| fileInput.dispatchEvent(changeEvent); | |
| }) | |
| .catch(err => { | |
| console.error('Error converting extracted image:', err); | |
| showMessage('Failed to process .sto file', 'error'); | |
| }); | |
| } else { | |
| showMessage('Failed to extract image from .sto file', 'error'); | |
| } | |
| }) | |
| .catch(err => { | |
| console.error('Error extracting from .sto file:', err); | |
| showMessage('Failed to process .sto file', 'error'); | |
| }); | |
| } else { | |
| // Regular image file | |
| const response = await fetch(`/api/get_testimage/spatiotemporal_detection/${encodeURIComponent(filename)}`); | |
| if (!response.ok) { | |
| throw new Error(`Failed to load ${filename}: ${response.statusText}`); | |
| } | |
| const blob = await response.blob(); | |
| const ext = filename.split('.').pop().toLowerCase(); | |
| const mimeTypes = { | |
| 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png', | |
| 'gif': 'image/gif', 'webp': 'image/webp', 'heic': 'image/heic', 'HEIC': 'image/heic' | |
| }; | |
| const mimeType = mimeTypes[ext] || blob.type || 'image/jpeg'; | |
| // Extract just the filename (basename) in case filename contains a path | |
| const basename = getBasename(filename); | |
| const file = new File([blob], basename, { type: mimeType, lastModified: Date.now() }); | |
| const dataTransfer = new DataTransfer(); | |
| dataTransfer.items.add(file); | |
| // Clear the input first, then set files | |
| fileInput.value = ''; | |
| fileInput.files = dataTransfer.files; | |
| // Trigger change event to process the file and display preview | |
| const changeEvent = new Event('change', { bubbles: true }); | |
| fileInput.dispatchEvent(changeEvent); | |
| } | |
| } | |
| } catch (error) { | |
| console.error('Error loading file from browser:', error); | |
| alert(`Error loading file: ${error.message}. Please try again.`); | |
| } | |
| } | |
| function formatFileSize(bytes) { | |
| if (bytes === 0) return '0 Bytes'; | |
| const k = 1024; | |
| const sizes = ['Bytes', 'KB', 'MB', 'GB']; | |
| const i = Math.floor(Math.log(bytes) / Math.log(k)); | |
| return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; | |
| } | |
| // File input change handlers - these work for both file browser modal and direct file selection | |
| fileInput.addEventListener('change', function(e) { | |
| if (e.target.files && e.target.files.length > 0) { | |
| handleFileSelect(e); | |
| } | |
| }); | |
| batchFileInput.addEventListener('change', function(e) { | |
| if (e.target.files && e.target.files.length > 0) { | |
| handleBatchFileSelect(e); | |
| } | |
| }); | |
| // Do not pre-populate Performance & Architecture; update only after Detect | |
| // Initially disable model selections - they will be enabled after image upload | |
| const spatialHeadSelect = document.getElementById('spatialHeadSelect'); | |
| const materialHeadSelect = document.getElementById('materialHeadSelect'); | |
| if (spatialHeadSelect) spatialHeadSelect.disabled = true; | |
| if (materialHeadSelect) materialHeadSelect.disabled = true; | |
| // Update detect button state (will be disabled until models/weights selected) | |
| updateDetectButtonState(); | |
| // Ensure dropdowns are not preselected; user must choose | |
| if (spatialHeadSelect) spatialHeadSelect.selectedIndex = 0; | |
| if (materialHeadSelect) materialHeadSelect.selectedIndex = 0; | |
| // Hide weight groups initially; show only after head selection | |
| const spatialWeightGroup = document.getElementById('spatialWeightGroup'); | |
| const materialWeightGroup = document.getElementById('materialWeightGroup'); | |
| if (spatialWeightGroup) spatialWeightGroup.style.display = 'none'; | |
| if (materialWeightGroup) materialWeightGroup.style.display = 'none'; | |
| // Update labels based on selected heads and toggle visibility | |
| if (spatialHeadSelect) spatialHeadSelect.addEventListener('change', () => { | |
| const val = spatialHeadSelect.value; | |
| const lbl = document.getElementById('spatialWeightLabel'); | |
| if (lbl) lbl.textContent = val ? `${val.toUpperCase()} weight:` : 'Object classifier weight:'; | |
| if (spatialWeightGroup) spatialWeightGroup.style.display = val ? 'block' : 'none'; | |
| // Load weights for selected head | |
| loadSpatialWeights(val); | |
| // Update detect button state | |
| updateDetectButtonState(); | |
| }); | |
| if (materialHeadSelect) materialHeadSelect.addEventListener('change', () => { | |
| const val = materialHeadSelect.value; | |
| const selectedText = materialHeadSelect.options[materialHeadSelect.selectedIndex]?.text || ''; | |
| const lbl = document.getElementById('materialWeightLabel'); | |
| if (lbl) lbl.textContent = val ? `${selectedText} weight:` : 'Material classifier weight:'; | |
| if (materialWeightGroup) materialWeightGroup.style.display = val ? 'block' : 'none'; | |
| // Load weights for selected head | |
| loadMaterialWeights(val); | |
| // Update detect button state | |
| updateDetectButtonState(); | |
| }); | |
| // Keep hidden modelSelect synced with visible selects (for backend compatibility) | |
| const spatialWeightSelect = document.getElementById('spatialWeightSelect'); | |
| const materialWeightSelect = document.getElementById('materialWeightSelect'); | |
| const hiddenModelSelect = document.getElementById('modelSelect'); | |
| function syncHiddenSelect() { | |
| // Prefer material weight if chosen, else spatial | |
| const val = (materialWeightSelect && materialWeightSelect.value) || (spatialWeightSelect && spatialWeightSelect.value) || ''; | |
| if (hiddenModelSelect) hiddenModelSelect.value = val; | |
| } | |
| if (spatialWeightSelect) spatialWeightSelect.addEventListener('change', () => { | |
| syncHiddenSelect(); | |
| updateDetectButtonState(); | |
| // Update processing status | |
| const psEl = document.getElementById('processingStatusValue'); | |
| const spatialWeight = spatialWeightSelect.value; | |
| const materialWeight = materialWeightSelect.value; | |
| if (psEl) { | |
| psEl.textContent = (spatialWeight && materialWeight) ? 'Ready' : 'Not ready'; | |
| } | |
| }); | |
| if (materialWeightSelect) materialWeightSelect.addEventListener('change', () => { | |
| syncHiddenSelect(); | |
| updateDetectButtonState(); | |
| // Update processing status | |
| const psEl = document.getElementById('processingStatusValue'); | |
| const spatialWeight = spatialWeightSelect.value; | |
| const materialWeight = materialWeightSelect.value; | |
| if (psEl) { | |
| psEl.textContent = (spatialWeight && materialWeight) ? 'Ready' : 'Not ready'; | |
| } | |
| }); | |
| } | |
| function updateDetectButtonState() { | |
| const detectBtn = document.getElementById('detectBtn'); | |
| if (!detectBtn) { | |
| console.log('Detect button not found'); | |
| return; | |
| } | |
| // Check if file is selected | |
| if (!selectedFile) { | |
| detectBtn.disabled = true; | |
| console.log('Detect button disabled: No file selected'); | |
| return; | |
| } | |
| // Check if both spatial and material heads are selected | |
| const spatialHead = document.getElementById('spatialHeadSelect')?.value; | |
| const materialHead = document.getElementById('materialHeadSelect')?.value; | |
| if (!spatialHead || !materialHead) { | |
| detectBtn.disabled = true; | |
| console.log('Detect button disabled: Heads not selected', { spatialHead, materialHead }); | |
| return; | |
| } | |
| // Check if both weights are selected | |
| const spatialWeight = document.getElementById('spatialWeightSelect')?.value; | |
| const materialWeight = document.getElementById('materialWeightSelect')?.value; | |
| if (!spatialWeight || !materialWeight) { | |
| detectBtn.disabled = true; | |
| console.log('Detect button disabled: Weights not selected', { spatialWeight, materialWeight }); | |
| return; | |
| } | |
| // All conditions met, enable button | |
| detectBtn.disabled = false; | |
| console.log('Detect button enabled: All conditions met'); | |
| } | |
| async function loadSpatialWeights(spatialHead) { | |
| const selectEl = document.getElementById('spatialWeightSelect'); | |
| if (!selectEl) return; | |
| // Reset options | |
| selectEl.innerHTML = '<option value="">Loading weights...</option>'; | |
| let endpoint = ''; | |
| if (spatialHead === 'yolov3_custom') { | |
| endpoint = '/api/yolov3_weights'; | |
| } else if (spatialHead === 'yolov8_custom') { | |
| endpoint = '/api/yolov8_custom_weights'; | |
| } else if (spatialHead === 'dinov3_custom') { | |
| endpoint = '/api/dinov3_weights'; | |
| } else { | |
| selectEl.innerHTML = '<option value="">Select spatial head...</option>'; | |
| return; | |
| } | |
| try { | |
| const res = await fetch(endpoint); | |
| const data = await res.json(); | |
| const weights = Array.isArray(data?.weights) ? data.weights : (Array.isArray(data) ? data : []); | |
| if (!weights.length) { | |
| selectEl.innerHTML = '<option value="">No weights available</option>'; | |
| return; | |
| } | |
| selectEl.innerHTML = '<option value="">Select weight...</option>'; | |
| // Repo link removed per user request (yolov8/dinov3) | |
| weights.forEach(w => { | |
| const path = w.path || w.weight_path || w.file || ''; | |
| const display = w.display_name || w.name || w.label || (w.path ? w.path.split('/').pop() : path.split('/').pop()); | |
| const opt = document.createElement('option'); | |
| opt.value = path; | |
| opt.textContent = display || 'weight'; | |
| selectEl.appendChild(opt); | |
| }); | |
| } catch (e) { | |
| console.error('Failed to load spatial weights', e); | |
| selectEl.innerHTML = '<option value="">Failed to load weights</option>'; | |
| } | |
| } | |
| function loadMaterialWeights(headValue) { | |
| const materialWeightSelect = document.getElementById('materialWeightSelect'); | |
| if (materialWeightSelect) { | |
| materialWeightSelect.innerHTML = '<option value="">Loading weights...</option>'; | |
| } | |
| if (!headValue || headValue !== 'custom_material_classifier') { | |
| if (materialWeightSelect) materialWeightSelect.innerHTML = '<option value="">Select a weight...</option>'; | |
| return; | |
| } | |
| // For custom material classifier, load weights from material detection head | |
| fetch('/api/material_detection_head_weights') | |
| .then(r => r.json()) | |
| .then(data => { | |
| const sel = document.getElementById('materialWeightSelect'); | |
| if (!sel) return; | |
| sel.innerHTML = ''; | |
| const weights = (data && data.success && Array.isArray(data.weights)) ? data.weights : []; | |
| if (!weights.length) { | |
| sel.innerHTML = '<option value="">No weights found</option>'; | |
| return; | |
| } | |
| sel.innerHTML = '<option value="">Select a weight...</option>'; | |
| // Repo link removed per user request (yolov8/dinov3) | |
| weights.forEach(w => { | |
| const opt = document.createElement('option'); | |
| opt.value = w.path || ''; | |
| opt.textContent = w.display_name || w.filename || (w.path ? w.path.split('/').pop() : 'weight'); | |
| sel.appendChild(opt); | |
| }); | |
| }) | |
| .catch(err => { | |
| console.error('Error loading material weights:', err); | |
| const sel = document.getElementById('materialWeightSelect'); | |
| if (sel) sel.innerHTML = '<option value="">Error loading weights</option>'; | |
| }); | |
| } | |
| function updateFileMetrics(file) { | |
| // For .sto files, always use the original .sto file for metrics | |
| const fileToUse = (originalStoFile && originalStoFile.name.toLowerCase().endsWith('.sto')) ? originalStoFile : file; | |
| // File size | |
| const fileSizeKB = (fileToUse.size / 1024).toFixed(2); | |
| const fileSizeEl = document.getElementById('fileSizeValue'); | |
| if (fileSizeEl) fileSizeEl.textContent = `${fileSizeKB} KB`; | |
| // Upload time | |
| const uploadTime = new Date().toLocaleTimeString(); | |
| const uploadTimeEl = document.getElementById('uploadTimeValue'); | |
| if (uploadTimeEl) uploadTimeEl.textContent = uploadTime; | |
| // Processing status: show when image selected | |
| const psElUp = document.getElementById('processingStatusValue'); | |
| const spatialWeight = document.getElementById('spatialWeightSelect')?.value; | |
| const materialWeight = document.getElementById('materialWeightSelect')?.value; | |
| if (psElUp) { | |
| psElUp.textContent = (spatialWeight && materialWeight) ? 'Ready' : 'Not ready'; | |
| } | |
| // Helper function to extract basename from a filename (handles paths with / or \ or :) | |
| function getBasename(filename) { | |
| if (!filename) return ''; | |
| // Handle colon-separated paths (Mac format) and normal paths | |
| return filename.split(':').pop().split('/').pop().split('\\').pop(); | |
| } | |
| // Update filename display - ALWAYS use original .sto filename if available | |
| const filenameEl = document.getElementById('filenameValue'); | |
| if (filenameEl) { | |
| // For .sto files, always show the original .sto filename with .sto extension | |
| if (originalStoFile && originalStoFile.name.toLowerCase().endsWith('.sto')) { | |
| filenameEl.textContent = getBasename(originalStoFile.name); | |
| } else { | |
| filenameEl.textContent = getBasename(fileToUse.name); | |
| } | |
| } | |
| } | |
| function updateImageMetrics(img) { | |
| // Image dimensions → write to transient image dimensions | |
| const transientEl = document.getElementById('transientImageDimensions'); | |
| if (transientEl) transientEl.textContent = `${img.width} × ${img.height}`; | |
| // For 2D intensity image dimensions (same as transient for regular images) | |
| const intensityEl = document.getElementById('intensityImageDimensions'); | |
| if (intensityEl) intensityEl.textContent = `${img.width} × ${img.height}`; | |
| // Processing status: clear after image metrics updated | |
| const ps = document.getElementById('processingStatusValue'); | |
| if (ps) ps.textContent = ''; | |
| } | |
| function updateStoImageDimensions() { | |
| // Update dimensions from .sto file indices | |
| if (stoIndex0) { | |
| // Load index 0 image to get dimensions | |
| const img0 = new Image(); | |
| img0.onload = function() { | |
| const transientEl = document.getElementById('transientImageDimensions'); | |
| if (transientEl) transientEl.textContent = `${img0.width} × ${img0.height}`; | |
| }; | |
| img0.src = stoIndex0; | |
| } | |
| if (stoIndex1) { | |
| // Load index 1 image to get dimensions | |
| const img1 = new Image(); | |
| img1.onload = function() { | |
| const intensityEl = document.getElementById('intensityImageDimensions'); | |
| if (intensityEl) intensityEl.textContent = `${img1.width} × ${img1.height}`; | |
| }; | |
| img1.src = stoIndex1; | |
| } | |
| } | |
| function handleFileSelect(e) { | |
| const file = e.target.files[0]; | |
| if (!file) return; | |
| const lower = file.name.toLowerCase(); | |
| // Check if it's a .sto file | |
| if (lower.endsWith('.sto')) { | |
| // Store original .sto file for inference | |
| originalStoFile = file; | |
| stoIndex0 = null; | |
| stoIndex1 = null; | |
| // Set filename immediately to show the .sto filename (basename only) | |
| const filenameEl = document.getElementById('filenameValue'); | |
| if (filenameEl) { | |
| filenameEl.textContent = getBasename(file.name); | |
| } | |
| const formData0 = new FormData(); | |
| formData0.append('file', file); | |
| const formData1 = new FormData(); | |
| formData1.append('file', file); | |
| // Extract both indices from .sto file | |
| Promise.all([ | |
| fetch('/api/extract_sto_index0', { method: 'POST', body: formData0 }) | |
| .then(r => r.json()), | |
| fetch('/api/extract_sto_index1', { method: 'POST', body: formData1 }) | |
| .then(r => r.json()) | |
| ]) | |
| .then(([data0, data1]) => { | |
| // Store index 0 | |
| if (data0 && data0.success && data0.image) { | |
| let imageDataUrl0 = data0.image; | |
| if (!imageDataUrl0.startsWith('data:')) { | |
| imageDataUrl0 = 'data:image/png;base64,' + imageDataUrl0; | |
| } | |
| stoIndex0 = imageDataUrl0; | |
| } | |
| // Extract and display index 1 for preview | |
| if (data1 && data1.success && data1.image) { | |
| let imageDataUrl1 = data1.image; | |
| if (!imageDataUrl1.startsWith('data:')) { | |
| imageDataUrl1 = 'data:image/png;base64,' + imageDataUrl1; | |
| } | |
| stoIndex1 = imageDataUrl1; | |
| // Update dimensions from .sto indices | |
| updateStoImageDimensions(); | |
| // Create a blob from the extracted image and use it for preview | |
| fetch(imageDataUrl1) | |
| .then(res => res.blob()) | |
| .then(blob => { | |
| const extractedFile = new File([blob], file.name.replace('.sto', '.png'), { type: 'image/png' }); | |
| selectedFile = extractedFile; | |
| // Ensure filename is set to original .sto filename (basename only) | |
| const filenameEl = document.getElementById('filenameValue'); | |
| if (filenameEl && originalStoFile) { | |
| filenameEl.textContent = getBasename(originalStoFile.name); | |
| } | |
| updateFileMetrics(file); // Use original .sto file for metrics | |
| displayImagePreview(extractedFile); | |
| updateDetectButtonState(); | |
| }) | |
| .catch(err => { | |
| console.error('Error converting extracted image:', err); | |
| showMessage('Failed to process .sto file', 'error'); | |
| }); | |
| } else { | |
| showMessage('Failed to extract image from .sto file', 'error'); | |
| } | |
| }) | |
| .catch(err => { | |
| console.error('Error extracting from .sto file:', err); | |
| showMessage('Failed to process .sto file', 'error'); | |
| }); | |
| } else { | |
| // Regular image file (not .sto) | |
| // Only clear originalStoFile if this is truly a new regular image file | |
| // (not an extracted file from a .sto file that was already processed) | |
| if (!originalStoFile || !file.name.toLowerCase().endsWith('.png') || !originalStoFile.name.toLowerCase().endsWith('.sto')) { | |
| // This is a new regular image file, not extracted from .sto | |
| originalStoFile = null; | |
| stoIndex0 = null; | |
| stoIndex1 = null; | |
| } | |
| selectedFile = file; | |
| updateFileMetrics(file); | |
| displayImagePreview(file); | |
| updateDetectButtonState(); | |
| } | |
| // Reset file input to allow uploading the same file again | |
| e.target.value = ''; | |
| } | |
| function handleBatchFileSelect(e) { | |
| const files = Array.from(e.target.files); | |
| if (files.length === 0) return; | |
| // Process first file to determine if we need to extract from .sto | |
| const firstFile = files[0]; | |
| const lower = firstFile.name.toLowerCase(); | |
| if (lower.endsWith('.sto')) { | |
| // Store original .sto file for inference | |
| originalStoFile = firstFile; | |
| stoIndex0 = null; | |
| stoIndex1 = null; | |
| // Set filename immediately to show the .sto filename (basename only) | |
| const filenameEl = document.getElementById('filenameValue'); | |
| if (filenameEl) { | |
| filenameEl.textContent = getBasename(firstFile.name); | |
| } | |
| const formData0 = new FormData(); | |
| formData0.append('file', firstFile); | |
| const formData1 = new FormData(); | |
| formData1.append('file', firstFile); | |
| // Extract both indices from .sto file | |
| Promise.all([ | |
| fetch('/api/extract_sto_index0', { method: 'POST', body: formData0 }) | |
| .then(r => r.json()), | |
| fetch('/api/extract_sto_index1', { method: 'POST', body: formData1 }) | |
| .then(r => r.json()) | |
| ]) | |
| .then(([data0, data1]) => { | |
| // Store index 0 | |
| if (data0 && data0.success && data0.image) { | |
| let imageDataUrl0 = data0.image; | |
| if (!imageDataUrl0.startsWith('data:')) { | |
| imageDataUrl0 = 'data:image/png;base64,' + imageDataUrl0; | |
| } | |
| stoIndex0 = imageDataUrl0; | |
| } | |
| // Extract and display index 1 for preview | |
| if (data1 && data1.success && data1.image) { | |
| let imageDataUrl1 = data1.image; | |
| if (!imageDataUrl1.startsWith('data:')) { | |
| imageDataUrl1 = 'data:image/png;base64,' + imageDataUrl1; | |
| } | |
| stoIndex1 = imageDataUrl1; | |
| // Update dimensions from .sto indices | |
| updateStoImageDimensions(); | |
| fetch(imageDataUrl1) | |
| .then(res => res.blob()) | |
| .then(blob => { | |
| const extractedFile = new File([blob], firstFile.name.replace('.sto', '.png'), { type: 'image/png' }); | |
| // For batch, we'll process .sto files on demand | |
| batchFiles = files; | |
| currentBatchIndex = 0; | |
| selectedFile = extractedFile; | |
| // Ensure filename is set to original .sto filename (basename only) | |
| const filenameEl = document.getElementById('filenameValue'); | |
| if (filenameEl && originalStoFile) { | |
| filenameEl.textContent = getBasename(originalStoFile.name); | |
| } | |
| updateFileMetrics(firstFile); // Use original .sto file for metrics | |
| displayImagePreview(extractedFile); | |
| showBatchNavigation(); | |
| updateDetectButtonState(); | |
| }) | |
| .catch(err => { | |
| console.error('Error converting extracted image:', err); | |
| showMessage('Failed to process .sto file', 'error'); | |
| }); | |
| } else { | |
| showMessage('Failed to extract image from .sto file', 'error'); | |
| } | |
| }) | |
| .catch(err => { | |
| console.error('Error extracting from .sto file:', err); | |
| showMessage('Failed to process .sto file', 'error'); | |
| }); | |
| } else { | |
| // Regular image files | |
| originalStoFile = null; | |
| stoIndex0 = null; | |
| stoIndex1 = null; | |
| batchFiles = files; | |
| currentBatchIndex = 0; | |
| selectedFile = files[0]; | |
| updateFileMetrics(files[0]); | |
| displayImagePreview(files[0]); | |
| showBatchNavigation(); | |
| updateDetectButtonState(); | |
| } | |
| // Reset file input to allow uploading the same files again | |
| e.target.value = ''; | |
| } | |
| function displayImagePreview(file) { | |
| const reader = new FileReader(); | |
| reader.onload = function(e) { | |
| const img = new Image(); | |
| img.onload = function() { | |
| const canvas = document.getElementById('imagePreview'); | |
| const ctx = canvas.getContext('2d'); | |
| const maxWidth = 400; | |
| const maxHeight = 300; | |
| const width = img.width; | |
| const height = img.height; | |
| // Calculate display dimensions | |
| let displayWidth = width; | |
| let displayHeight = height; | |
| if (width > maxWidth || height > maxHeight) { | |
| const ratio = Math.min(maxWidth / width, maxHeight / height); | |
| displayWidth = width * ratio; | |
| displayHeight = height * ratio; | |
| } | |
| // Set canvas size to display dimensions | |
| canvas.width = displayWidth; | |
| canvas.height = displayHeight; | |
| // Enable image smoothing for better quality | |
| ctx.imageSmoothingEnabled = true; | |
| ctx.imageSmoothingQuality = 'high'; | |
| currentImage = img; | |
| // Show image preview container | |
| document.getElementById('imagePreviewContainer').style.display = 'block'; | |
| // Enable model selections after image is uploaded | |
| enableModelSelection(); | |
| // Initialize crop values to full image dimensions but don't show overlay | |
| // Store crop values in variables since input fields were removed | |
| window.cropX = 0; | |
| window.cropY = 0; | |
| window.cropW = displayWidth; | |
| window.cropH = displayHeight; | |
| // Hide crop overlay initially - only show when user drags | |
| const overlay = document.getElementById('cropOverlay'); | |
| if (overlay) { | |
| overlay.style.display = 'none'; | |
| } | |
| // Draw image with current transforms | |
| redrawImageWithTransforms(); | |
| const detectionResultsContainer = document.getElementById('detectionResultsContainer'); | |
| detectionResultsContainer.style.display = 'none'; | |
| // Update detect button state | |
| updateDetectButtonState(); | |
| console.log('Image loaded:', { | |
| originalWidth: img.width, | |
| originalHeight: img.height, | |
| displayWidth: displayWidth, | |
| displayHeight: displayHeight, | |
| filename: file.name | |
| }); | |
| }; | |
| img.src = e.target.result; | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| function enableModelSelection() { | |
| // Show model selection div after image is uploaded | |
| const imageControls = document.getElementById('imageControls'); | |
| if (imageControls) { | |
| // Remove the inline style attribute completely to allow CSS to take effect | |
| imageControls.removeAttribute('style'); | |
| // Force show using inline styles (without !important - JavaScript can't set !important) | |
| imageControls.style.display = 'block'; | |
| imageControls.style.visibility = 'visible'; | |
| imageControls.style.opacity = '1'; | |
| imageControls.style.height = 'auto'; | |
| imageControls.style.overflow = 'visible'; | |
| imageControls.style.marginBottom = '25px'; | |
| // Add a class to ensure visibility | |
| imageControls.classList.add('model-selection-visible'); | |
| imageControls.classList.remove('model-selection-hidden'); | |
| console.log('Model selection div shown:', imageControls.style.display); | |
| console.log('Model selection div computed style:', window.getComputedStyle(imageControls).display); | |
| console.log('Model selection div is visible:', window.getComputedStyle(imageControls).display !== 'none'); | |
| } else { | |
| console.error('ERROR: imageControls element not found!'); | |
| } | |
| // Enable spatial and material head selects after image upload | |
| const spatialHeadSelect = document.getElementById('spatialHeadSelect'); | |
| const materialHeadSelect = document.getElementById('materialHeadSelect'); | |
| if (spatialHeadSelect) { | |
| spatialHeadSelect.disabled = false; | |
| console.log('Spatial head select enabled'); | |
| } else { | |
| console.error('ERROR: spatialHeadSelect element not found!'); | |
| } | |
| if (materialHeadSelect) { | |
| materialHeadSelect.disabled = false; | |
| console.log('Material head select enabled'); | |
| } else { | |
| console.error('ERROR: materialHeadSelect element not found!'); | |
| } | |
| // Update detect button state (will be disabled until models/weights selected) | |
| updateDetectButtonState(); | |
| console.log('Detect button state updated'); | |
| } | |
| async function loadModelWeights() { | |
| try { | |
| const response = await fetch('/api/yolov8_custom_weights'); | |
| const data = await response.json(); | |
| const modelSelect = document.getElementById('modelSelect'); | |
| modelSelect.innerHTML = ''; | |
| if (data.success && data.weights) { | |
| data.weights.forEach(weight => { | |
| const option = document.createElement('option'); | |
| option.value = weight.path; | |
| option.textContent = weight.display_name; | |
| modelSelect.appendChild(option); | |
| }); | |
| } else { | |
| modelSelect.innerHTML = '<option value="">No weights available</option>'; | |
| } | |
| } catch (error) { | |
| console.error('Error loading model weights:', error); | |
| } | |
| } | |
| // Removed pre-load of architecture details; now populated only after Detect | |
| // Clearing function not needed when only updating on detection | |
| function setupCropHandlers() { | |
| // Crop tools are disabled/hidden - skip setting up handlers for performance | |
| // This prevents unnecessary event listeners that could slow down the page | |
| const canvas = document.getElementById('imagePreview'); | |
| if (canvas) { | |
| // Only prevent default drag behavior, but don't add crop event listeners | |
| canvas.setAttribute('draggable', 'false'); | |
| // Prevent context menu on right-click | |
| canvas.addEventListener('contextmenu', function(e) { | |
| e.preventDefault(); | |
| }); | |
| } | |
| console.log('Crop handlers disabled for performance'); | |
| } | |
| function startCrop(e) { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| isDragging = true; | |
| const canvas = document.getElementById('imagePreview'); | |
| const rect = canvas.getBoundingClientRect(); | |
| cropStartX = e.clientX - rect.left; | |
| cropStartY = e.clientY - rect.top; | |
| // Show overlay immediately, even with 0 width/height | |
| const overlay = document.getElementById('cropOverlay'); | |
| if (overlay) { | |
| overlay.style.left = cropStartX + 'px'; | |
| overlay.style.top = cropStartY + 'px'; | |
| overlay.style.width = '0px'; | |
| overlay.style.height = '0px'; | |
| overlay.style.display = 'block'; | |
| overlay.style.border = '2px dashed #B91D30'; | |
| overlay.style.backgroundColor = 'rgba(185, 29, 48, 0.1)'; | |
| } | |
| } | |
| function updateCrop(e) { | |
| if (!isDragging) return; | |
| if (!e) return; | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| const canvas = document.getElementById('imagePreview'); | |
| const rect = canvas.getBoundingClientRect(); | |
| const currentX = e.clientX - rect.left; | |
| const currentY = e.clientY - rect.top; | |
| // Constrain to canvas bounds | |
| const canvasWidth = canvas.width; | |
| const canvasHeight = canvas.height; | |
| const clampedX = Math.max(0, Math.min(currentX, canvasWidth)); | |
| const clampedY = Math.max(0, Math.min(currentY, canvasHeight)); | |
| const cropX = Math.min(cropStartX, clampedX); | |
| const cropY = Math.min(cropStartY, clampedY); | |
| const cropW = Math.abs(clampedX - cropStartX); | |
| const cropH = Math.abs(clampedY - cropStartY); | |
| // Always show overlay when dragging, even if small | |
| const overlay = document.getElementById('cropOverlay'); | |
| if (overlay) { | |
| overlay.style.left = cropX + 'px'; | |
| overlay.style.top = cropY + 'px'; | |
| overlay.style.width = cropW + 'px'; | |
| overlay.style.height = cropH + 'px'; | |
| overlay.style.display = 'block'; | |
| overlay.style.border = '2px dashed #B91D30'; | |
| overlay.style.backgroundColor = 'rgba(185, 29, 48, 0.1)'; | |
| } | |
| // Update global crop variables | |
| window.cropX = Math.round(cropX); | |
| window.cropY = Math.round(cropY); | |
| window.cropW = Math.round(cropW); | |
| window.cropH = Math.round(cropH); | |
| } | |
| function endCrop(e) { | |
| if (e) { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| } | |
| isDragging = false; | |
| updateCropPreview(); | |
| } | |
| function updateCropOverlay(x, y, w, h) { | |
| const overlay = document.getElementById('cropOverlay'); | |
| if (!overlay) return; | |
| // Only show overlay if there's an actual crop selection (not default full image) | |
| const canvas = document.getElementById('imagePreview'); | |
| const isActualCrop = (x > 0 || y > 0 || w < canvas.width || h < canvas.height) && w > 0 && h > 0; | |
| if (isActualCrop) { | |
| overlay.style.left = x + 'px'; | |
| overlay.style.top = y + 'px'; | |
| overlay.style.width = w + 'px'; | |
| overlay.style.height = h + 'px'; | |
| overlay.style.display = 'block'; | |
| overlay.style.border = '2px solid #B91D30'; | |
| overlay.style.backgroundColor = 'rgba(185, 29, 48, 0.1)'; | |
| } else { | |
| overlay.style.display = 'none'; | |
| } | |
| } | |
| function updateCropPreview() { | |
| if (!currentImage) return; | |
| const canvas = document.getElementById('imagePreview'); | |
| const cropX = window.cropX || 0; | |
| const cropY = window.cropY || 0; | |
| const cropW = window.cropW; | |
| const cropH = window.cropH; | |
| updateCropOverlay(cropX, cropY, cropW, cropH); | |
| } | |
| function isCropActive() { | |
| const cropX = window.cropX || 0; | |
| const cropY = window.cropY || 0; | |
| const cropW = window.cropW; | |
| const cropH = window.cropH; | |
| const canvas = document.getElementById('imagePreview'); | |
| // Only consider crop active if: | |
| // 1. User has moved from default position (X > 0 or Y > 0), OR | |
| // 2. User has reduced size from full image dimensions | |
| return (cropX > 0 || cropY > 0 || | |
| cropW < canvas.width || cropH < canvas.height); | |
| } | |
| function resetCrop() { | |
| if (!currentImage) return; | |
| // Reset transforms | |
| currentRotation = 0; | |
| currentFlip = { | |
| horizontal: false, | |
| vertical: false | |
| }; | |
| // Update status display | |
| document.getElementById('rotationAngle').textContent = '0°'; | |
| document.getElementById('flipStatus').textContent = 'None'; | |
| const canvas = document.getElementById('imagePreview'); | |
| window.cropX = 0; | |
| window.cropY = 0; | |
| window.cropW = canvas.width; | |
| window.cropH = canvas.height; | |
| // Hide overlay when resetting to full image | |
| const overlay = document.getElementById('cropOverlay'); | |
| if (overlay) { | |
| overlay.style.display = 'none'; | |
| } | |
| // Redraw image without transforms | |
| redrawImageWithTransforms(); | |
| updateCropPreview(); | |
| } | |
| function rotateImage(degrees) { | |
| currentRotation = (currentRotation + degrees) % 360; | |
| if (currentRotation < 0) currentRotation += 360; | |
| document.getElementById('rotationAngle').textContent = currentRotation + '°'; | |
| if (currentImage) { | |
| redrawImageWithTransforms(); | |
| updateCropPreview(); | |
| } | |
| } | |
| function flipImage(direction) { | |
| if (direction === 'horizontal') { | |
| currentFlip.horizontal = !currentFlip.horizontal; | |
| } else if (direction === 'vertical') { | |
| currentFlip.vertical = !currentFlip.vertical; | |
| } | |
| const flipStatus = []; | |
| if (currentFlip.horizontal) flipStatus.push('H'); | |
| if (currentFlip.vertical) flipStatus.push('V'); | |
| document.getElementById('flipStatus').textContent = flipStatus.length > 0 ? flipStatus.join(', ') : 'None'; | |
| if (currentImage) { | |
| redrawImageWithTransforms(); | |
| updateCropPreview(); | |
| } | |
| } | |
| function redrawImageWithTransforms() { | |
| if (!currentImage) return; | |
| const canvas = document.getElementById('imagePreview'); | |
| const ctx = canvas.getContext('2d'); | |
| const maxWidth = 400; | |
| const maxHeight = 300; | |
| const width = currentImage.width; | |
| const height = currentImage.height; | |
| // Calculate display dimensions | |
| let displayWidth = width; | |
| let displayHeight = height; | |
| if (width > maxWidth || height > maxHeight) { | |
| const ratio = Math.min(maxWidth / width, maxHeight / height); | |
| displayWidth = width * ratio; | |
| displayHeight = height * ratio; | |
| } | |
| // Set canvas size to display dimensions | |
| canvas.width = displayWidth; | |
| canvas.height = displayHeight; | |
| // Enable image smoothing for better quality | |
| ctx.imageSmoothingEnabled = true; | |
| ctx.imageSmoothingQuality = 'high'; | |
| // Clear canvas | |
| ctx.clearRect(0, 0, displayWidth, displayHeight); | |
| // Save context state | |
| ctx.save(); | |
| // Move to center of canvas | |
| ctx.translate(displayWidth / 2, displayHeight / 2); | |
| // Apply rotation | |
| if (currentRotation !== 0) { | |
| ctx.rotate((currentRotation * Math.PI) / 180); | |
| } | |
| // Apply flips | |
| let scaleX = 1; | |
| let scaleY = 1; | |
| if (currentFlip.horizontal) scaleX = -1; | |
| if (currentFlip.vertical) scaleY = -1; | |
| ctx.scale(scaleX, scaleY); | |
| // Draw image centered | |
| ctx.drawImage(currentImage, -displayWidth / 2, -displayHeight / 2, displayWidth, displayHeight); | |
| // Restore context state | |
| ctx.restore(); | |
| // Update crop values to match new display dimensions | |
| if (!isCropActive()) { | |
| window.cropX = 0; | |
| window.cropY = 0; | |
| window.cropW = displayWidth; | |
| window.cropH = displayHeight; | |
| } | |
| console.log('Image redrawn with transforms:', { | |
| rotation: currentRotation, | |
| flip: currentFlip, | |
| displayWidth: displayWidth, | |
| displayHeight: displayHeight | |
| }); | |
| } | |
| function getOriginalImageData() { | |
| // Return the original image data URL | |
| return new Promise((resolve) => { | |
| const canvas = document.createElement('canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| canvas.width = currentImage.width; | |
| canvas.height = currentImage.height; | |
| // Draw original image without any transformations | |
| ctx.drawImage(currentImage, 0, 0); | |
| resolve(canvas.toDataURL('image/jpeg', 0.9)); | |
| }); | |
| } | |
| function setupBatchNavigation() { | |
| const prevBtn = document.getElementById('prevImageBtn'); | |
| const nextBtn = document.getElementById('nextImageBtn'); | |
| prevBtn.addEventListener('click', () => { | |
| if (currentBatchIndex > 0) { | |
| currentBatchIndex--; | |
| selectedFile = batchFiles[currentBatchIndex]; | |
| updateFileMetrics(selectedFile); | |
| displayImagePreview(selectedFile); | |
| updateBatchCounter(); | |
| updateDetectButtonState(); | |
| updateBatchButtons(); | |
| } | |
| }); | |
| nextBtn.addEventListener('click', () => { | |
| if (currentBatchIndex < batchFiles.length - 1) { | |
| currentBatchIndex++; | |
| selectedFile = batchFiles[currentBatchIndex]; | |
| updateFileMetrics(selectedFile); | |
| displayImagePreview(selectedFile); | |
| updateBatchCounter(); | |
| updateDetectButtonState(); | |
| updateBatchButtons(); | |
| } | |
| }); | |
| } | |
| function showBatchNavigation() { | |
| document.getElementById('batchNavigation').style.display = 'flex'; | |
| updateBatchCounter(); | |
| updateBatchButtons(); | |
| } | |
| function updateBatchCounter() { | |
| document.getElementById('batchCounter').textContent = `${currentBatchIndex + 1} of ${batchFiles.length}`; | |
| } | |
| function updateBatchButtons() { | |
| const prevBtn = document.getElementById('prevImageBtn'); | |
| const nextBtn = document.getElementById('nextImageBtn'); | |
| prevBtn.disabled = currentBatchIndex === 0; | |
| nextBtn.disabled = currentBatchIndex === batchFiles.length - 1; | |
| } | |
| async function detectImage() { | |
| // Check if file is selected | |
| if (!selectedFile) { | |
| showMessage('Please select an image', 'error'); | |
| return; | |
| } | |
| // Check if both spatial and material heads are selected | |
| const spatialHead = document.getElementById('spatialHeadSelect')?.value; | |
| const materialHead = document.getElementById('materialHeadSelect')?.value; | |
| if (!spatialHead || !materialHead) { | |
| showMessage('Please select both spatial and material detection heads', 'error'); | |
| return; | |
| } | |
| // Check if both weights are selected | |
| const spatialWeight = document.getElementById('spatialWeightSelect')?.value; | |
| const materialWeight = document.getElementById('materialWeightSelect')?.value; | |
| if (!spatialWeight || !materialWeight) { | |
| showMessage('Please select weights for both spatial and material detection heads', 'error'); | |
| return; | |
| } | |
| // Check if image is selected (either STO file or regular image) | |
| const isStoFile = originalStoFile && stoIndex0 && stoIndex1; | |
| const isRegularImage = selectedFile && !selectedFile.name.toLowerCase().endsWith('.sto'); | |
| if (!isStoFile && !isRegularImage) { | |
| showMessage('Please select an image file', 'error'); | |
| return; | |
| } | |
| // Update transient image dimensions from index 0 | |
| if (stoIndex0) { | |
| const img0 = new Image(); | |
| img0.onload = function() { | |
| const transientEl = document.getElementById('transientImageDimensions'); | |
| if (transientEl) transientEl.textContent = `${img0.width} × ${img0.height}`; | |
| }; | |
| img0.src = stoIndex0; | |
| } | |
| const detectBtn = document.getElementById('detectBtn'); | |
| const loading = document.getElementById('loading'); | |
| const detectionResultsContainer = document.getElementById('detectionResultsContainer'); | |
| detectBtn.disabled = true; | |
| detectBtn.innerHTML = 'Processing...'; | |
| loading.style.display = 'block'; | |
| detectionResultsContainer.style.display = 'block'; | |
| hideMessages(); | |
| // Enable hpWhiteboardContainer and hpGifImage when detect button is clicked | |
| const hpWhiteboardContainer = document.getElementById('hpWhiteboardContainer'); | |
| const hpGifImage = document.getElementById('hpGifImage'); | |
| const whiteboardCaption = document.getElementById('whiteboardCaption'); | |
| if (hpWhiteboardContainer) { | |
| hpWhiteboardContainer.style.display = 'flex'; | |
| } | |
| if (hpGifImage) { | |
| hpGifImage.style.display = 'block'; | |
| } | |
| if (whiteboardCaption) { | |
| whiteboardCaption.style.display = 'block'; | |
| } | |
| // Update processing status | |
| const psStart = document.getElementById('processingStatusValue'); | |
| if (psStart) psStart.textContent = 'Processing...'; | |
| // Update architecture values | |
| try { | |
| const sHeadEl = document.getElementById('spatialHeadSelect'); | |
| const mHeadEl = document.getElementById('materialHeadSelect'); | |
| const sHeadText = sHeadEl && sHeadEl.options && sHeadEl.selectedIndex >= 0 ? sHeadEl.options[sHeadEl.selectedIndex].text : ''; | |
| const mHeadText = mHeadEl && mHeadEl.options && mHeadEl.selectedIndex >= 0 ? mHeadEl.options[mHeadEl.selectedIndex].text : ''; | |
| document.getElementById('architectureValue').textContent = sHeadText || '-'; | |
| document.getElementById('materialArchitectureValue').textContent = mHeadText || '-'; | |
| document.getElementById('inferenceTimeValue').textContent = 'No inference'; | |
| document.getElementById('materialInferenceTimeValue').textContent = 'No inference'; | |
| } catch (e) {} | |
| try { | |
| // 1) Material detection request | |
| const matForm = new FormData(); | |
| matForm.append('weight_path', materialWeight); | |
| // Debug: Log the weight path being sent | |
| console.log('DEBUG: Sending material detection request'); | |
| console.log('DEBUG: Material head:', materialHead); | |
| console.log('DEBUG: Material weight path:', materialWeight); | |
| // Use STO index 0 (which contains index 1 from STO: 16x16 material detection image) if available, | |
| // otherwise use the selected regular image | |
| if (isStoFile && stoIndex0) { | |
| console.log('DEBUG: Using stoIndex0 (which contains index 1 from STO: 16x16 material detection image)'); | |
| // Convert base64 image to blob for material detection (index 1 from STO, stored in stoIndex0) | |
| const base64Data0 = stoIndex0.split(',')[1] || stoIndex0; | |
| const byteCharacters0 = atob(base64Data0); | |
| const byteNumbers0 = new Array(byteCharacters0.length); | |
| for (let i = 0; i < byteCharacters0.length; i++) { | |
| byteNumbers0[i] = byteCharacters0.charCodeAt(i); | |
| } | |
| const byteArray0 = new Uint8Array(byteNumbers0); | |
| const blob0 = new Blob([byteArray0], { type: 'image/png' }); | |
| const index0File = new File([blob0], 'index0_material.png', { type: 'image/png' }); | |
| matForm.append('file', index0File); | |
| } else if (isRegularImage && selectedFile) { | |
| console.log('DEBUG: Using regular image for material detection:', selectedFile.name); | |
| matForm.append('file', selectedFile); | |
| } else { | |
| showMessage('No image available for material detection', 'error'); | |
| return; | |
| } | |
| const matReq = fetch('/api/detect_material_head', { method: 'POST', body: matForm }) | |
| .then(r => r.json()) | |
| .catch(e => ({ success: false, error: String(e) })); | |
| // 2) Spatial object detection request | |
| let spatialEndpoint = ''; | |
| if (spatialHead === 'yolov3_custom') spatialEndpoint = '/api/detect_yolov3'; | |
| else if (spatialHead === 'yolov8_custom') spatialEndpoint = '/api/detect_yolov8_custom'; | |
| else if (spatialHead === 'dinov3_custom') spatialEndpoint = '/api/detect_dinov3'; | |
| const spaForm = new FormData(); | |
| spaForm.append('weight_path', spatialWeight); | |
| // Debug: Log the weight path being sent | |
| console.log('DEBUG: Sending spatial detection request'); | |
| console.log('DEBUG: Spatial head:', spatialHead); | |
| console.log('DEBUG: Spatial weight path:', spatialWeight); | |
| console.log('DEBUG: Spatial endpoint:', spatialEndpoint); | |
| // Get the current canvas image (with transformations applied) | |
| const imageCanvas = document.getElementById('imagePreview'); | |
| let spatialImageData; | |
| // Crop tools are disabled - always use full image for better performance | |
| // Skip crop check to avoid unnecessary processing | |
| spatialImageData = imageCanvas.toDataURL('image/jpeg', 0.9); | |
| // Convert data URL to blob for spatial detection | |
| const spatialImageResponse = await fetch(spatialImageData); | |
| const spatialBlob = await spatialImageResponse.blob(); | |
| const spatialFileName = isStoFile ? 'spatial_index1.png' : selectedFile.name; | |
| spaForm.append('file', spatialBlob, spatialFileName); | |
| // Display transformed spatial input preview | |
| const finalImg = document.getElementById('finalInputPreview'); | |
| if (finalImg && spatialImageData) { | |
| finalImg.src = spatialImageData; | |
| finalImg.style.display = 'block'; | |
| } | |
| const spaReq = spatialEndpoint ? fetch(spatialEndpoint, { method: 'POST', body: spaForm }) | |
| .then(r => r.json()) | |
| .catch(e => ({ success: false, error: String(e) })) | |
| : Promise.resolve({ success: false, error: 'No spatial head selected' }); | |
| // Make simultaneous calls | |
| const [matRes, spaRes] = await Promise.all([matReq, spaReq]); | |
| console.log('Material response:', matRes); | |
| console.log('Spatial response:', spaRes); | |
| // Debug: Check if predictions are correct | |
| if (spaRes && spaRes.success) { | |
| console.log('DEBUG: Spatial detection successful'); | |
| console.log('DEBUG: Spatial top3_predictions:', spaRes.top3_predictions); | |
| console.log('DEBUG: Number of spatial predictions:', spaRes.top3_predictions ? spaRes.top3_predictions.length : 0); | |
| } else { | |
| console.error('DEBUG: Spatial detection failed:', spaRes?.error || 'Unknown error'); | |
| } | |
| if (matRes && matRes.success) { | |
| console.log('DEBUG: Material detection successful'); | |
| console.log('DEBUG: Material top3_predictions:', matRes.top3_predictions); | |
| console.log('DEBUG: Number of material predictions:', matRes.top3_predictions ? matRes.top3_predictions.length : 0); | |
| } else { | |
| console.error('DEBUG: Material detection failed:', matRes?.error || 'Unknown error'); | |
| } | |
| const psDone = document.getElementById('processingStatusValue'); | |
| if (psDone) psDone.textContent = 'Inference Complete'; | |
| // Update results and metrics | |
| displayResultsCombined(matRes, spaRes); | |
| // Update input file metrics - use original .sto file if available, otherwise use selected file | |
| if (originalStoFile) { | |
| updateFileMetrics(originalStoFile); | |
| } else if (selectedFile) { | |
| updateFileMetrics(selectedFile); | |
| } | |
| } catch (error) { | |
| console.error('Detection error:', error); | |
| showMessage('Detection failed: ' + error.message, 'error'); | |
| const psError = document.getElementById('processingStatusValue'); | |
| if (psError) psError.textContent = 'Error'; | |
| } finally { | |
| detectBtn.innerHTML = 'Scene Detection'; | |
| updateDetectButtonState(); | |
| loading.style.display = 'none'; | |
| } | |
| } | |
| function displayResultsCombined(materialData, spatialData) { | |
| // Update inference times only if an inference was actually made | |
| if (spatialData && spatialData.success && typeof spatialData.inference_time === 'number') { | |
| const inferenceTimeEl = document.getElementById('inferenceTimeValue'); | |
| if (inferenceTimeEl) inferenceTimeEl.textContent = `${spatialData.inference_time.toFixed(1)}ms`; | |
| } | |
| if (materialData && materialData.success && typeof materialData.inference_time === 'number') { | |
| const materialInferenceTimeEl = document.getElementById('materialInferenceTimeValue'); | |
| if (materialInferenceTimeEl) materialInferenceTimeEl.textContent = `${materialData.inference_time.toFixed(1)}ms`; | |
| } | |
| // Show results container | |
| const resultsContainer = document.getElementById('detectionResultsContainer'); | |
| if (resultsContainer) { | |
| resultsContainer.style.display = 'block'; | |
| resultsContainer.style.visibility = 'visible'; | |
| resultsContainer.style.opacity = '1'; | |
| } | |
| // Ensure Performance & Architecture section is visible | |
| const perfArchSection = document.querySelector('.performance-stats, .architecture-details, .performance-metrics'); | |
| if (perfArchSection) { | |
| perfArchSection.style.display = 'block'; | |
| perfArchSection.style.visibility = 'visible'; | |
| } | |
| // Reset all predictions first | |
| for (let i = 1; i <= 6; i++) { | |
| const el = document.getElementById(`prediction${i}`); | |
| if (el) el.textContent = `${i <= 3 ? i : i - 3}. -`; | |
| } | |
| const matCountEl = document.getElementById('detectionCountMaterials'); | |
| if (matCountEl) matCountEl.textContent = 'Found no material(s)'; | |
| const objCountEl = document.getElementById('detectionCountObjects'); | |
| if (objCountEl) objCountEl.textContent = 'Found no object(s)'; | |
| // If spatialData has an image with boxes already drawn, use that | |
| if (spatialData && spatialData.image) { | |
| const finalImg = document.getElementById('finalInputPreview'); | |
| if (finalImg) { | |
| finalImg.src = spatialData.image; | |
| finalImg.style.display = 'block'; | |
| } | |
| } else if (spatialData && !spatialData.success) { | |
| console.error('Spatial detection failed:', spatialData.error); | |
| } | |
| // Update whiteboardCaption | |
| const whiteboardCaption = document.getElementById('whiteboardCaption'); | |
| // Enable whiteboardCaption | |
| if (whiteboardCaption) { | |
| whiteboardCaption.style.display = 'block'; | |
| } | |
| // Generate whiteboard caption with detected object, material, and random values | |
| if (whiteboardCaption) { | |
| // Check if detections lists are empty - check both spatial and material separately | |
| // Use top3_predictions for spatial (same as material) to get the highest confidence prediction | |
| // A valid detection must have: non-empty class, class not '-', and probability > 0 | |
| const hasSpatialDetections = spatialData && spatialData.top3_predictions && spatialData.top3_predictions.length > 0 && | |
| spatialData.top3_predictions[0] && spatialData.top3_predictions[0].class && | |
| spatialData.top3_predictions[0].class !== '-' && | |
| (spatialData.top3_predictions[0].probability || 0) > 0; | |
| const hasMaterialDetections = materialData && materialData.top3_predictions && materialData.top3_predictions.length > 0 && | |
| materialData.top3_predictions[0] && materialData.top3_predictions[0].class && | |
| materialData.top3_predictions[0].class !== '-' && | |
| (materialData.top3_predictions[0].probability || 0) > 0; | |
| // Helper function to generate random value from range (can be decimal/continuous) | |
| function randomFromRange(min, max) { | |
| return (Math.random() * (max - min) + min).toFixed(1); | |
| } | |
| // Determine what to display on whiteboard based on what was detected | |
| let caption = ''; | |
| // Generate random values (used for all cases with material info) | |
| const targetDist = randomFromRange(20, 30); | |
| const targetBg = ['unknown', 'wood', 'null', 'fabric', 'unknown'][Math.floor(Math.random() * 5)]; | |
| const targetBgDist = randomFromRange(50, 100); | |
| const targetFg = ['null', 'unknown', 'plastic', 'glass'][Math.floor(Math.random() * 4)]; | |
| const targetFgDist = randomFromRange(10, 15); | |
| if (!hasSpatialDetections && !hasMaterialDetections) { | |
| // Neither detected | |
| caption = 'No objects detected.\nNo materials detected.'; | |
| } else if (!hasSpatialDetections && hasMaterialDetections) { | |
| // Only material detected - still show full material-related information | |
| const topMaterial = materialData.top3_predictions[0]; | |
| const detectedMaterial = topMaterial.display_class || topMaterial.class; | |
| caption = `No objects detected.\n${detectedMaterial} material detected at ${targetDist}cm.\n\nTarget foreground may contain ${targetFg} material at ~${targetFgDist}cm.\n\nTarget background may contain ${targetBg} material at ~${targetBgDist}cm.`; | |
| } else if (hasSpatialDetections && !hasMaterialDetections) { | |
| // Only spatial detected - use the highest confidence prediction | |
| const topSpatial = spatialData.top3_predictions[0]; | |
| const detectedObject = topSpatial.display_class || topSpatial.class; | |
| caption = `${detectedObject} object detected at ${targetDist}cm.\nNo materials detected.`; | |
| } else { | |
| // Both detected - show full caption | |
| // Use the highest confidence prediction (first in top3_predictions) | |
| const topSpatial = spatialData.top3_predictions[0]; | |
| const detectedObject = topSpatial.display_class || topSpatial.class; | |
| const topMaterial = materialData.top3_predictions[0]; | |
| const detectedMaterial = topMaterial.display_class || topMaterial.class; | |
| caption = `${detectedObject} object of ${detectedMaterial} material detected at ${targetDist}cm.\n\nTarget foreground may contain ${targetFg} material at ~${targetFgDist}cm.\n\nTarget background may contain ${targetBg} material at ~${targetBgDist}cm.`; | |
| } | |
| whiteboardCaption.textContent = caption; | |
| } | |
| // Helper function to normalize probabilities to sum to 100% | |
| // This ensures the top 3 predictions displayed sum to 100% | |
| function normalizeProbabilities(predictions) { | |
| if (!predictions || predictions.length === 0) return []; | |
| // Filter out invalid predictions | |
| const validPredictions = predictions.filter(p => p && p.class && p.class !== '-' && (p.probability || 0) > 0); | |
| if (validPredictions.length === 0) return predictions; | |
| // Calculate total probability of valid predictions | |
| const total = validPredictions.reduce((sum, p) => sum + (p.probability || 0), 0); | |
| if (total === 0) return predictions; | |
| // Normalize each valid probability so they sum to 100% | |
| return predictions.map(p => { | |
| if (p && p.class && p.class !== '-' && (p.probability || 0) > 0) { | |
| return { | |
| ...p, | |
| probability: (p.probability || 0) / total | |
| }; | |
| } | |
| return p; | |
| }); | |
| } | |
| // Material predictions on right (prediction1..3) | |
| if (materialData && materialData.top3_predictions) { | |
| const mats = normalizeProbabilities(materialData.top3_predictions).slice(0, 3); | |
| while (mats.length < 3) mats.push({ class: '-', probability: 0 }); | |
| for (let i = 0; i < 3; i++) { | |
| const el = document.getElementById(`prediction${i + 1}`); | |
| const p = mats[i]; | |
| if (el) { | |
| if (p.class !== '-' && p.probability > 0) { | |
| // Use display_class if available, otherwise use class | |
| const displayName = p.display_class || p.class; | |
| el.textContent = `${i + 1}. ${displayName} - ${(p.probability * 100).toFixed(2)}%`; | |
| } else { | |
| el.textContent = `${i + 1}. -`; | |
| } | |
| } | |
| } | |
| const matCountEl = document.getElementById('detectionCountMaterials'); | |
| if (matCountEl) { | |
| const validMats = mats.filter(p => p.class !== '-' && p.probability > 0); | |
| // Count total number of returned predictions (all valid predictions) | |
| const totalMats = materialData.top3_predictions ? materialData.top3_predictions.filter(p => p && p.class && p.class !== '-' && (p.probability || 0) > 0).length : 0; | |
| matCountEl.textContent = totalMats > 0 | |
| ? `Found ${totalMats} material(s)` | |
| : 'Found no material(s)'; | |
| } | |
| } | |
| // Spatial predictions on left (prediction4..6) | |
| if (spatialData && spatialData.top3_predictions) { | |
| const objs = normalizeProbabilities(spatialData.top3_predictions).slice(0, 3); | |
| while (objs.length < 3) objs.push({ class: '-', probability: 0 }); | |
| for (let i = 0; i < 3; i++) { | |
| const el = document.getElementById(`prediction${i + 4}`); | |
| const p = objs[i]; | |
| if (el) { | |
| if (p.class !== '-' && p.probability > 0) { | |
| el.textContent = `${i + 1}. ${p.class} - ${(p.probability * 100).toFixed(2)}%`; | |
| } else { | |
| el.textContent = `${i + 1}. -`; | |
| } | |
| } | |
| } | |
| const objCountEl = document.getElementById('detectionCountObjects'); | |
| if (objCountEl) { | |
| const validObjs = objs.filter(p => p.class !== '-' && p.probability > 0); | |
| // Count total number of returned predictions (all valid predictions) | |
| const totalObjs = spatialData.top3_predictions ? spatialData.top3_predictions.filter(p => p && p.class && p.class !== '-' && (p.probability || 0) > 0).length : 0; | |
| objCountEl.textContent = totalObjs > 0 | |
| ? `Found ${totalObjs} object(s)` | |
| : 'Found no object(s)'; | |
| } | |
| } | |
| } | |
| function displayResults(data) { | |
| const modelSelect = document.getElementById('modelSelect'); | |
| if (!modelSelect || !modelSelect.value) { | |
| return; | |
| } | |
| // Update performance stats with null checks | |
| const inferenceTimeEl = document.getElementById('inferenceTime'); | |
| const modelSizeEl = document.getElementById('modelSize'); | |
| const inputSizeEl = document.getElementById('inputSize'); | |
| const batchSizeEl = document.getElementById('batchSize'); | |
| const usedWeightPathEl = document.getElementById('usedWeightPath'); | |
| const inferredNumClassesEl = document.getElementById('inferredNumClasses'); | |
| const architectureEl = document.getElementById('architecture'); | |
| const backboneEl = document.getElementById('backbone'); | |
| const detectionHeadsEl = document.getElementById('detectionHeads'); | |
| const anchorsEl = document.getElementById('anchors'); | |
| const fitnessScoreEl = document.getElementById('fitnessScore'); | |
| if (inferenceTimeEl) { | |
| inferenceTimeEl.textContent = `${data.inference_time.toFixed(1)}ms`; | |
| } | |
| if (modelSizeEl) { | |
| modelSizeEl.textContent = data.model_size || '--'; | |
| } | |
| if (inputSizeEl) { | |
| inputSizeEl.textContent = data.input_size || '--'; | |
| } | |
| if (batchSizeEl) { | |
| batchSizeEl.textContent = (data.batch_size ?? '--'); | |
| } | |
| if (usedWeightPathEl && data.used_weight_path) { | |
| // Extract only the filename from the full path | |
| const filename = data.used_weight_path.split('/').pop() || data.used_weight_path; | |
| usedWeightPathEl.textContent = filename; | |
| } | |
| if (inferredNumClassesEl && typeof data.inferred_num_classes !== 'undefined') { | |
| inferredNumClassesEl.textContent = data.inferred_num_classes; | |
| } | |
| // Update architecture details if available | |
| if (architectureEl) { | |
| architectureEl.textContent = data.model_type || 'SV2'; | |
| } | |
| if (backboneEl) { | |
| backboneEl.textContent = data.backbone || '--'; | |
| } | |
| if (detectionHeadsEl) { | |
| detectionHeadsEl.textContent = data.detection_heads || '--'; | |
| } | |
| if (anchorsEl) { | |
| anchorsEl.textContent = data.anchors || '--'; | |
| } | |
| // Classes stat-card was removed, so skip updating it | |
| if (fitnessScoreEl) { | |
| fitnessScoreEl.textContent = (typeof data.fitness_score !== 'undefined') ? data.fitness_score : '--'; | |
| } | |
| const resultImage = document.getElementById('resultImage'); | |
| if (!resultImage) { | |
| console.error('resultImage element not found'); | |
| return; | |
| } | |
| resultImage.src = data.image; | |
| resultImage.onload = function() { | |
| console.log('Result image loaded successfully'); | |
| console.log('Detection result image:', { | |
| hasCrop: isCropActive(), | |
| imageSrcLength: data.image ? data.image.length : 0, | |
| shouldShowCrop: isCropActive() ? 'Only cropped patch' : 'Full image' | |
| }); | |
| }; | |
| resultImage.onerror = function() { | |
| console.error('Failed to load result image'); | |
| }; | |
| const predictionResult = document.getElementById('predictionResult'); | |
| const predictionClass = document.getElementById('predictionClass'); | |
| const predictionConfidence = document.getElementById('predictionConfidence'); | |
| // Check if elements exist before accessing | |
| if (!predictionResult || !predictionClass || !predictionConfidence) { | |
| console.error('Missing prediction elements:', { | |
| predictionResult: !!predictionResult, | |
| predictionClass: !!predictionClass, | |
| predictionConfidence: !!predictionConfidence | |
| }); | |
| return; | |
| } | |
| // Handle object detection results | |
| if (data.detections && data.detections.length > 0) { | |
| // Show the first detection (highest confidence) | |
| const firstDetection = data.detections[0]; | |
| predictionClass.textContent = `${firstDetection.class} - Confidence: ${(firstDetection.confidence * 100).toFixed(2)}%`; | |
| predictionConfidence.textContent = ''; // Clear the separate confidence display | |
| // Show detection count | |
| const detectionCount = document.createElement('div'); | |
| detectionCount.textContent = `Found ${data.detections.length} object(s)`; | |
| detectionCount.style.fontSize = '0.9rem'; | |
| detectionCount.style.color = '#cccccc'; | |
| detectionCount.style.marginTop = '5px'; | |
| // Clear previous detection count | |
| const existingCount = predictionResult.querySelector('.detection-count'); | |
| if (existingCount) { | |
| existingCount.remove(); | |
| } | |
| detectionCount.className = 'detection-count'; | |
| predictionResult.appendChild(detectionCount); | |
| } else { | |
| // No detections found | |
| predictionClass.textContent = 'No objects detected'; | |
| predictionConfidence.textContent = 'Confidence: 0.00%'; | |
| // Show detection count for no objects | |
| const detectionCount = document.createElement('div'); | |
| detectionCount.textContent = 'Found no object(s)'; | |
| detectionCount.style.fontSize = '0.9rem'; | |
| detectionCount.style.color = '#cccccc'; | |
| detectionCount.style.marginTop = '5px'; | |
| // Clear previous detection count | |
| const existingCount = predictionResult.querySelector('.detection-count'); | |
| if (existingCount) { | |
| existingCount.remove(); | |
| } | |
| detectionCount.className = 'detection-count'; | |
| predictionResult.appendChild(detectionCount); | |
| } | |
| predictionResult.style.display = 'block'; | |
| // Display top 3 predictions - ALWAYS show exactly 3 from ALL classes | |
| const prediction1 = document.getElementById('prediction1'); | |
| const prediction2 = document.getElementById('prediction2'); | |
| const prediction3 = document.getElementById('prediction3'); | |
| // Check if prediction elements exist | |
| if (!prediction1 || !prediction2 || !prediction3) { | |
| console.error('Missing prediction elements:', { | |
| prediction1: !!prediction1, | |
| prediction2: !!prediction2, | |
| prediction3: !!prediction3 | |
| }); | |
| return; | |
| } | |
| console.log('DEBUG: prediction1 element:', prediction1); | |
| console.log('DEBUG: prediction2 element:', prediction2); | |
| console.log('DEBUG: prediction3 element:', prediction3); | |
| console.log('DEBUG: top3_predictions data:', data.top3_predictions); | |
| console.log('DEBUG: detections length:', data.detections ? data.detections.length : 0); | |
| // Check if any objects were detected | |
| const hasDetections = data.detections && data.detections.length > 0; | |
| if (hasDetections) { | |
| // Show actual predictions when objects are detected | |
| if (data.top3_predictions && data.top3_predictions.length >= 1 && data.top3_predictions[0].class !== 'none') { | |
| prediction1.textContent = `1. ${data.top3_predictions[0].class}: ${(data.top3_predictions[0].probability * 100).toFixed(2)}%`; | |
| console.log('DEBUG: Set prediction1 to:', prediction1.textContent); | |
| } else { | |
| prediction1.textContent = '1. -'; | |
| console.log('DEBUG: Set prediction1 to dash'); | |
| } | |
| if (data.top3_predictions && data.top3_predictions.length >= 2 && data.top3_predictions[1].class !== 'none') { | |
| prediction2.textContent = `2. ${data.top3_predictions[1].class}: ${(data.top3_predictions[1].probability * 100).toFixed(2)}%`; | |
| console.log('DEBUG: Set prediction2 to:', prediction2.textContent); | |
| } else { | |
| prediction2.textContent = '2. -'; | |
| console.log('DEBUG: Set prediction2 to dash'); | |
| } | |
| if (data.top3_predictions && data.top3_predictions.length >= 3 && data.top3_predictions[2].class !== 'none') { | |
| prediction3.textContent = `3. ${data.top3_predictions[2].class}: ${(data.top3_predictions[2].probability * 100).toFixed(2)}%`; | |
| console.log('DEBUG: Set prediction3 to:', prediction3.textContent); | |
| } else { | |
| prediction3.textContent = '3. -'; | |
| console.log('DEBUG: Set prediction3 to dash'); | |
| } | |
| // Store all_predictions for expand/collapse functionality | |
| if (data.all_predictions && data.all_predictions.length > 0) { | |
| window.allPredictionsData = data.all_predictions; | |
| updateAllPredictionsDisplay(); | |
| } else { | |
| window.allPredictionsData = []; | |
| const expandBtn = document.getElementById('expandPredictionsBtn'); | |
| if (expandBtn) { | |
| expandBtn.style.display = 'none'; | |
| } | |
| } | |
| } else { | |
| // Show dashes when no objects are detected | |
| prediction1.textContent = '1. -'; | |
| prediction2.textContent = '2. -'; | |
| prediction3.textContent = '3. -'; | |
| console.log('DEBUG: No detections found, showing all dashes'); | |
| window.allPredictionsData = []; | |
| const expandBtn = document.getElementById('expandPredictionsBtn'); | |
| if (expandBtn) { | |
| expandBtn.style.display = 'none'; | |
| } | |
| } | |
| } | |
| function toggleAllPredictions() { | |
| const allPredictionsDiv = document.getElementById('allPredictions'); | |
| const expandBtn = document.getElementById('expandPredictionsBtn'); | |
| if (allPredictionsDiv && expandBtn) { | |
| if (allPredictionsDiv.style.display === 'none') { | |
| allPredictionsDiv.style.display = 'block'; | |
| expandBtn.textContent = 'Hide All Predictions'; | |
| } else { | |
| allPredictionsDiv.style.display = 'none'; | |
| expandBtn.textContent = 'Show All Predictions'; | |
| } | |
| } | |
| } | |
| function updateAllPredictionsDisplay() { | |
| const allPredictionsDiv = document.getElementById('allPredictions'); | |
| const expandBtn = document.getElementById('expandPredictionsBtn'); | |
| if (!allPredictionsDiv || !window.allPredictionsData) return; | |
| // Clear existing content | |
| allPredictionsDiv.innerHTML = ''; | |
| // Display all predictions | |
| window.allPredictionsData.forEach((pred, index) => { | |
| const predEntry = document.createElement('div'); | |
| predEntry.className = 'prediction-entry'; | |
| predEntry.style.marginBottom = '4px'; | |
| predEntry.textContent = `${index + 1}. ${pred.class}: ${(pred.probability * 100).toFixed(2)}%`; | |
| allPredictionsDiv.appendChild(predEntry); | |
| }); | |
| // Show/hide button based on number of predictions | |
| if (expandBtn) { | |
| if (window.allPredictionsData.length > 3) { | |
| expandBtn.style.display = 'block'; | |
| } else { | |
| expandBtn.style.display = 'none'; | |
| } | |
| } | |
| } | |
| function showMessage(message, type) { | |
| hideMessages(); | |
| const messageDiv = document.getElementById(type === 'error' ? 'errorMessage' : 'successMessage'); | |
| messageDiv.textContent = message; | |
| messageDiv.style.display = 'block'; | |
| setTimeout(() => { | |
| messageDiv.style.display = 'none'; | |
| }, 5000); | |
| } | |
| function hideMessages() { | |
| document.getElementById('errorMessage').style.display = 'none'; | |
| document.getElementById('successMessage').style.display = 'none'; | |
| } | |
| // Add event listener for detect button | |
| document.addEventListener('DOMContentLoaded', function() { | |
| const detectBtn = document.getElementById('detectBtn'); | |
| if (detectBtn) { | |
| detectBtn.addEventListener('click', detectImage); | |
| } | |
| }); | |
| </script> | |
| {% endblock %} |