spad_for_vision / templates /spatiotemporal_detection.html
0001AMA's picture
SPAD for Vision: app and docs
e5f2dfe
{% 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 !important;
grid-template-columns: 1fr 1.92fr 0.8fr !important;
gap: 20px !important;
margin-bottom: 30px !important;
margin-top: 0 !important;
min-height: auto !important;
padding: 0 !important;
overflow-y: visible !important;
flex-direction: row !important;
}
.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 !important;
}
/* Ensure model selection is visible - override inline styles */
#imageControls {
margin-bottom: 25px !important;
display: block !important;
visibility: visible !important;
opacity: 1 !important;
height: auto !important;
overflow: visible !important;
}
/* Additional class-based override for when JavaScript removes inline style */
#imageControls.model-selection-visible {
display: block !important;
visibility: visible !important;
opacity: 1 !important;
}
.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 !important;
font-size: 1.2rem !important;
display: inline-block !important;
font-weight: bold !important;
text-align: center !important;
vertical-align: middle !important;
}
.transform-btn-small {
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
.transform-btn-small:hover {
background: rgba(185, 29, 48, 0.3);
border-color: #ffffff;
}
.transform-btn-small:hover i {
color: #ffffff !important;
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 !important;
gap: 8px;
margin-bottom: 10px;
justify-content: center;
visibility: visible !important;
opacity: 1 !important;
}
.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 !important;
}
.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"></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">&times;</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 %}