Spaces:
Sleeping
Sleeping
| // Basic front-end logic | |
| document.addEventListener('DOMContentLoaded', () => { | |
| // UI Elements | |
| const dropZone = document.getElementById('drop-zone'); | |
| const fileInput = document.getElementById('file-input'); | |
| const fileList = document.getElementById('file-list'); | |
| const uploadText = document.getElementById('upload-text'); | |
| const inputMode = document.getElementById('input-mode'); | |
| const startProcessingBtn = document.getElementById('start-processing'); | |
| const confidenceSlider = document.getElementById('confidence-threshold'); | |
| const confidenceValue = document.getElementById('confidence-value'); | |
| const progress = document.getElementById('progress'); | |
| const progressText = document.getElementById('progress-text'); | |
| const progressPercentage = document.getElementById('progress-percentage'); | |
| const imageCounter = document.getElementById('image-counter'); | |
| const statusOutput = document.getElementById('status-output'); | |
| const clearLogBtn = document.getElementById('clear-log'); | |
| const resultsTableBody = document.querySelector('.results-table tbody'); | |
| const previewImage = document.getElementById('preview-image'); | |
| const imageInfo = document.getElementById('image-info'); | |
| const prevBtn = document.getElementById('prev-image'); | |
| const nextBtn = document.getElementById('next-image'); | |
| const zoomInBtn = document.getElementById('zoom-in'); | |
| const zoomOutBtn = document.getElementById('zoom-out'); | |
| const exportCsvBtn = document.getElementById('export-csv'); | |
| const exportImagesBtn = document.getElementById('export-images'); | |
| const inputModeHelp = document.getElementById('input-mode-help'); | |
| const imageContainer = document.getElementById('image-container'); | |
| let currentResults = []; | |
| let currentImageIndex = -1; | |
| let currentJobId = null; | |
| let currentZoomLevel = 1; | |
| let filenameMap = {}; | |
| const MAX_ZOOM = 3; | |
| const MIN_ZOOM = 0.5; | |
| let progressInterval = null; // Interval timer for polling | |
| let filteredValidFiles = []; | |
| // Panning variables | |
| let isPanning = false; | |
| let startPanX = 0; | |
| let startPanY = 0; | |
| let currentPanX = 0; | |
| let currentPanY = 0; | |
| // Pagination and sorting variables | |
| const RESULTS_PER_PAGE = 10; | |
| let currentPage = 1; | |
| let totalPages = 1; | |
| let currentSortField = null; | |
| let currentSortDirection = 'asc'; | |
| // --- Store all detections for frontend filtering --- | |
| let allDetections = []; | |
| let allImageData = {}; | |
| // Input mode change | |
| inputMode.addEventListener('change', () => { | |
| const mode = inputMode.value; | |
| if (mode === 'files') { | |
| fileInput.removeAttribute('webkitdirectory'); | |
| fileInput.removeAttribute('directory'); | |
| fileInput.setAttribute('multiple', ''); | |
| inputModeHelp.textContent = 'Choose one or more image files for processing'; | |
| uploadText.textContent = 'Drag and drop images here or click to browse'; | |
| } else if (mode === 'folder') { | |
| fileInput.setAttribute('webkitdirectory', ''); | |
| fileInput.setAttribute('directory', ''); | |
| fileInput.removeAttribute('multiple'); | |
| inputModeHelp.textContent = 'Select a folder containing images to process'; | |
| uploadText.textContent = 'Click to select a folder containing images'; | |
| } else if (mode === 'keyence') { | |
| fileInput.setAttribute('webkitdirectory', ''); | |
| fileInput.setAttribute('directory', ''); | |
| fileInput.removeAttribute('multiple'); | |
| inputModeHelp.textContent = 'Select a Keyence output folder. Folder should have subdirectories of format XY01, XY02, etc.'; | |
| uploadText.textContent = 'Click to select a Keyence output folder'; | |
| } | |
| // Clear any existing files | |
| fileInput.value = ''; | |
| fileList.innerHTML = ''; | |
| updateUploadState(); | |
| }); | |
| // File Upload Handling | |
| function handleFiles(files) { | |
| const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/tiff', 'image/tif']; | |
| // --- Keyence folder validation --- | |
| if (inputMode.value === 'keyence') { | |
| // Collect all unique first-level subdirectory names from webkitRelativePath | |
| const subdirs = new Set(); | |
| Array.from(files).forEach(file => { | |
| if (file.webkitRelativePath) { | |
| const parts = file.webkitRelativePath.split('/'); | |
| if (parts.length > 2) { // Parent/Subdir/File | |
| subdirs.add(parts[1]); | |
| } | |
| } | |
| }); | |
| // Check for at least one subdir matching XY[0-9]{2} | |
| const xyPattern = /^XY\d{2}$/; | |
| const hasXY = Array.from(subdirs).some(name => xyPattern.test(name)); | |
| if (!hasXY) { | |
| // Print the first 5 values in subdirs for debugging | |
| const subdirArr = Array.from(subdirs).slice(0, 5); | |
| logStatus('First 5 subdirectories found: ' + (subdirArr.length ? subdirArr.join(', ') : '[none]')); | |
| logStatus('Error: The selected folder does not contain any subdirectory named like "XY01", "XY02", etc. Please select a valid Keyence output folder.'); | |
| fileList.innerHTML = ''; | |
| startProcessingBtn.disabled = true; | |
| return; | |
| } | |
| // --- Check for .csv file(s) --- | |
| const csvFiles = Array.from(files).filter(file => file.name.toLowerCase().endsWith('.csv')); | |
| if (csvFiles.length === 1) { | |
| // Show a custom dialog with multiple options | |
| showSingleCsvDialog(csvFiles[0]); | |
| return; | |
| } else if (csvFiles.length === 0) { | |
| const wantToUpload = window.confirm('No CSV key file was found in the folder. Would you like to supply a CSV key file now?'); | |
| if (wantToUpload) { | |
| // Create a hidden file input for CSV upload | |
| let csvInput = document.createElement('input'); | |
| csvInput.type = 'file'; | |
| csvInput.accept = '.csv,.CSV'; | |
| csvInput.style.display = 'none'; | |
| document.body.appendChild(csvInput); | |
| csvInput.addEventListener('change', function() { | |
| if (csvInput.files && csvInput.files.length === 1) { | |
| window.selectedKeyenceCsv = csvInput.files[0]; | |
| logStatus(`CSV key file supplied: ${csvInput.files[0].name}`); | |
| } else { | |
| window.selectedKeyenceCsv = null; | |
| logStatus('No CSV key file supplied.'); | |
| } | |
| document.body.removeChild(csvInput); | |
| }); | |
| csvInput.click(); | |
| } else { | |
| window.selectedKeyenceCsv = null; | |
| logStatus('No CSV key file will be used.'); | |
| } | |
| } else { | |
| // Multiple CSVs found, show a custom dialog for selection | |
| showCsvSelectionDialog(csvFiles); | |
| return; // Wait for user selection before proceeding | |
| } | |
| } | |
| let validFiles; | |
| if (inputMode.value === 'keyence') { | |
| // Only analyze the first valid image file (alphabetically) in each subdirectory | |
| const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/tiff', 'image/tif']; | |
| // Map: subdir name -> array of files in that subdir | |
| const subdirFiles = {}; | |
| Array.from(files).forEach(file => { | |
| if (file.webkitRelativePath) { | |
| const parts = file.webkitRelativePath.split('/'); | |
| if (parts.length > 2) { // Parent/Subdir/File | |
| const subdir = parts[1]; | |
| if (!subdirFiles[subdir]) subdirFiles[subdir] = []; | |
| if (allowedTypes.includes(file.type) && !file.webkitRelativePath.startsWith('.')) { | |
| subdirFiles[subdir].push(file); | |
| } | |
| } | |
| } | |
| }); | |
| // For each subdir, pick the first file alphabetically | |
| validFiles = Object.values(subdirFiles).map(filesArr => { | |
| return filesArr.sort((a, b) => a.name.localeCompare(b.name))[0]; | |
| }).filter(Boolean); // Remove undefined | |
| } else { | |
| validFiles = Array.from(files).filter(file => { | |
| if (inputMode.value === 'folder') { | |
| return allowedTypes.includes(file.type) && | |
| file.webkitRelativePath && | |
| !file.webkitRelativePath.startsWith('.'); | |
| } | |
| return allowedTypes.includes(file.type); | |
| }); | |
| } | |
| filteredValidFiles = validFiles; | |
| const invalidFiles = Array.from(files).filter(file => !allowedTypes.includes(file.type)); | |
| // Only print invalid file warnings if not in Keyence mode | |
| if (invalidFiles.length > 0 && inputMode.value !== 'keyence') { | |
| logStatus(`Warning: Skipped ${invalidFiles.length} invalid files. Only PNG, JPG, and TIFF are supported.`); | |
| invalidFiles.forEach(file => { | |
| logStatus(`- Skipped: ${file.name} (invalid type: ${file.type || 'unknown'})`); | |
| }); | |
| } | |
| if (validFiles.length === 0) { | |
| logStatus('Error: No valid image files selected.'); | |
| return; | |
| } | |
| fileList.innerHTML = ''; | |
| // Just show an icon to indicate files are selected | |
| const summaryDiv = document.createElement('div'); | |
| summaryDiv.className = 'file-summary'; | |
| summaryDiv.innerHTML = ` | |
| <div class="summary-header"> | |
| <i class="ri-file-list-3-line"></i> | |
| <span>Images ready for processing</span> | |
| </div> | |
| `; | |
| fileList.appendChild(summaryDiv); | |
| fileInput.files = files; | |
| updateUploadState(validFiles.length); | |
| } | |
| function formatFileSize(bytes) { | |
| if (bytes === 0) return '0 B'; | |
| const k = 1024; | |
| const sizes = ['B', 'KB', 'MB', 'GB']; | |
| const i = Math.floor(Math.log(bytes) / Math.log(k)); | |
| return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; | |
| } | |
| // Drag and Drop | |
| ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { | |
| dropZone.addEventListener(eventName, preventDefaults, false); | |
| document.body.addEventListener(eventName, preventDefaults, false); | |
| }); | |
| function preventDefaults(e) { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| } | |
| ['dragenter', 'dragover'].forEach(eventName => { | |
| dropZone.addEventListener(eventName, highlight, false); | |
| }); | |
| ['dragleave', 'drop'].forEach(eventName => { | |
| dropZone.addEventListener(eventName, unhighlight, false); | |
| }); | |
| function highlight() { | |
| dropZone.classList.add('drag-over'); | |
| } | |
| function unhighlight() { | |
| dropZone.classList.remove('drag-over'); | |
| } | |
| dropZone.addEventListener('drop', (e) => { | |
| const dt = e.dataTransfer; | |
| handleFiles(dt.files); | |
| }); | |
| // Click to upload | |
| dropZone.addEventListener('click', () => { | |
| fileInput.click(); | |
| }); | |
| fileInput.addEventListener('change', async () => { | |
| handleFiles(fileInput.files); | |
| if (filteredValidFiles && filteredValidFiles.length > 0) { | |
| // Prepare FormData for upload | |
| const formData = new FormData(); | |
| filteredValidFiles.forEach(f => formData.append('files', f)); | |
| try { | |
| const response = await fetch('/uploads', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| if (response.ok) { | |
| const data = await response.json(); | |
| logStatus('Files uploaded successfully.'); | |
| filenameMap = data.filename_map || {}; | |
| // Update results table with filenames and View buttons | |
| resultsTableBody.innerHTML = ''; | |
| Object.entries(filenameMap).forEach(([uuid, originalFilename], idx) => { | |
| const row = resultsTableBody.insertRow(); | |
| row.dataset.originalIndex = idx; | |
| row.innerHTML = ` | |
| <td>${originalFilename}</td> | |
| <td style="color:#bbb;">NA</td> | |
| <td><button class="view-button" data-index="${idx}">View</button></td> | |
| `; | |
| }); | |
| // Add click event for View buttons | |
| resultsTableBody.querySelectorAll('.view-button').forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| const idx = parseInt(btn.dataset.index, 10); | |
| displayImage(idx); | |
| }); | |
| }); | |
| } else { | |
| logStatus('File upload failed.'); | |
| } | |
| } catch (err) { | |
| logStatus('Error uploading files: ' + err); | |
| } | |
| } | |
| }); | |
| // Input mode change | |
| inputMode.addEventListener('change', () => { | |
| updateUploadState(); | |
| }); | |
| function updateUploadState(validFileCount) { | |
| // Use filteredValidFiles for enabling/disabling the button | |
| if (!filteredValidFiles || filteredValidFiles.length === 0) { | |
| if (inputMode.value === 'folder') { | |
| uploadText.textContent = 'Click to select a folder containing images'; | |
| } else if (inputMode.value === 'keyence') { | |
| uploadText.textContent = 'Click to select a Keyence output folder'; | |
| } else { | |
| uploadText.textContent = 'Drag and drop images here or click to browse'; | |
| } | |
| startProcessingBtn.disabled = true; | |
| } else { | |
| uploadText.textContent = `${validFileCount} image${validFileCount === 1 ? '' : 's'} selected`; | |
| startProcessingBtn.disabled = validFileCount === 0; | |
| // Populate results table with uuid/filename pairs from filenameMap after upload | |
| resultsTableBody.innerHTML = ''; | |
| Object.entries(filenameMap).forEach(([uuid, originalFilename], idx) => { | |
| const row = resultsTableBody.insertRow(); | |
| row.dataset.originalIndex = idx; | |
| row.innerHTML = ` | |
| <td>${originalFilename}</td> | |
| <td style="color:#bbb;">NA</td> | |
| <td><button class="view-button" data-uuid="${uuid}" data-index="${idx}">View</button></td> | |
| `; | |
| }); | |
| // Add click event for View buttons | |
| resultsTableBody.querySelectorAll('.view-button').forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| const uuid = btn.getAttribute('data-uuid'); | |
| displayImage(uuid); | |
| }); | |
| }); | |
| } | |
| } | |
| // Confidence threshold | |
| confidenceSlider.addEventListener('input', () => { | |
| confidenceValue.textContent = confidenceSlider.value; | |
| }); | |
| // Clear log | |
| clearLogBtn.addEventListener('click', () => { | |
| statusOutput.textContent = ''; | |
| logStatus('Log cleared'); | |
| }); | |
| // Processing | |
| startProcessingBtn.addEventListener('click', async () => { | |
| // Use filteredValidFiles for processing | |
| const files = filteredValidFiles; | |
| if (!files || files.length === 0) { | |
| logStatus('Error: No files selected.'); | |
| return; | |
| } | |
| const mode = inputMode.value; | |
| setLoading(true); | |
| logStatus('Starting upload and processing...'); | |
| updateProgress(0, 'Uploading files...'); | |
| // Do not clear resultsTableBody or preview image so users can browse existing results during processing | |
| currentResults = []; | |
| if (progressInterval) { | |
| clearInterval(progressInterval); | |
| progressInterval = null; | |
| } | |
| const formData = new FormData(); | |
| for (const file of files) { | |
| formData.append('files', file); | |
| } | |
| formData.append('input_mode', mode); | |
| formData.append('confidence_threshold', confidenceSlider.value); | |
| try { | |
| const response = await fetch('/process', { | |
| method: 'POST', | |
| body: formData, | |
| }); | |
| if (!response.ok) { | |
| let errorText = `HTTP error! status: ${response.status}`; | |
| try { | |
| const errorData = await response.json(); | |
| errorText += `: ${errorData.error || 'Unknown server error'}`; | |
| if (errorData.log) logStatus(`Server Log: ${errorData.log}`); | |
| } catch (e) { | |
| errorText += ` - ${response.statusText}`; | |
| } | |
| throw new Error(errorText); | |
| } | |
| const data = await response.json(); | |
| if (data.error) { | |
| logStatus(`Error starting process: ${data.error}`); | |
| if(data.log) logStatus(`Details: ${data.log}`); | |
| throw new Error(data.error); | |
| } | |
| // --- ASYNC JOB: Start polling for progress --- | |
| if (data.sessionId) { | |
| // logStatus(`Processing started. Job ID: ${data.sessionId}`); | |
| currentJobId = data.sessionId; | |
| pollProgress(currentJobId); | |
| } else { | |
| logStatus('Error: No sessionId returned from server.'); | |
| setLoading(false); | |
| } | |
| } catch (error) { | |
| logStatus(`Error: ${error.message}`); | |
| updateProgress(0, 'Error occurred'); | |
| setLoading(false); | |
| if (progressInterval) { | |
| clearInterval(progressInterval); | |
| progressInterval = null; | |
| } | |
| } | |
| }); | |
| // --- Filtering and Table Update --- | |
| function updateResultsTable() { | |
| const threshold = parseFloat(confidenceSlider.value); | |
| // Use allDetections (array of {uuid, detections}) for filtering | |
| const prevUuid = (currentImageIndex >= 0 && currentResults[currentImageIndex]) ? currentResults[currentImageIndex].uuid : null; | |
| currentResults = allDetections.map(imgResult => { | |
| const filtered = imgResult.detections.filter(det => det.score >= threshold); | |
| // Use filenameMap to convert uuid to original filename for display | |
| const originalFilename = filenameMap[imgResult.uuid] || imgResult.uuid; | |
| return { | |
| uuid: imgResult.uuid, | |
| filename: originalFilename, | |
| num_eggs: filtered.length, | |
| detections: filtered | |
| }; | |
| }); | |
| resultsTableBody.innerHTML = ''; | |
| currentSortField = null; | |
| currentSortDirection = 'asc'; | |
| totalPages = Math.ceil(currentResults.length / RESULTS_PER_PAGE); | |
| currentPage = 1; | |
| displayResultsPage(currentPage); | |
| // Try to restore previous image if it still exists | |
| let newIndex = 0; | |
| if (prevUuid) { | |
| newIndex = currentResults.findIndex(r => r.uuid === prevUuid); | |
| if (newIndex === -1) newIndex = 0; | |
| } | |
| currentImageIndex = newIndex; | |
| if (currentResults.length > 0) displayImage(currentImageIndex); | |
| // Enable/disable export buttons based on results | |
| if (currentResults.length > 0) { | |
| exportCsvBtn.disabled = false; | |
| exportImagesBtn.disabled = false; | |
| } else { | |
| exportCsvBtn.disabled = true; | |
| exportImagesBtn.disabled = true; | |
| } | |
| } | |
| confidenceSlider.addEventListener('input', () => { | |
| confidenceValue.textContent = confidenceSlider.value; | |
| // 1. Update total eggs detected | |
| const totalEggsElem = document.getElementById('total-eggs-count'); | |
| if (totalEggsElem && allDetections.length > 0) { | |
| const threshold = parseFloat(confidenceSlider.value); | |
| const totalEggs = allDetections.reduce((sum, imgResult) => sum + imgResult.detections.filter(det => det.score >= threshold).length, 0); | |
| totalEggsElem.textContent = totalEggs; | |
| } | |
| // 2. Redraw confidence plot | |
| renderConfidencePlot(); | |
| // 3. Update results table | |
| updateResultsTable(); | |
| // 4. Update eggs detected under image preview | |
| if (currentImageIndex >= 0) { | |
| displayImage(currentImageIndex); | |
| } | |
| }); | |
| // --- Replace displayImage to use backend-annotated PNG --- | |
| async function displayImage(index) { | |
| // Always use uuid, not filename, for backend requests | |
| let uuid = index; | |
| // If index is a number, get uuid from filenameMap | |
| if (typeof index === 'number') { | |
| uuid = Object.keys(filenameMap)[index]; | |
| currentImageIndex = index; | |
| } else { | |
| // If index is a uuid string, find its index for navigation | |
| currentImageIndex = Object.keys(filenameMap).indexOf(index); | |
| } | |
| let isCompleted = allDetections && allDetections.length > 0; | |
| const confidence = parseFloat(confidenceSlider.value); | |
| try { | |
| let response; | |
| if (isCompleted) { | |
| response = await fetch('/annotate', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ uuid: uuid, confidence }) | |
| }); | |
| } else { | |
| response = await fetch('/preview', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ uuid: uuid }) | |
| }); | |
| } | |
| if (response.ok) { | |
| const blob = await response.blob(); | |
| console.log((isCompleted ? 'Annotate' : 'Preview') + ' image blob type:', blob.type, 'uuid:', uuid); | |
| if (previewImage.src && previewImage.src.startsWith('blob:')) { | |
| URL.revokeObjectURL(previewImage.src); | |
| } | |
| const objectUrl = URL.createObjectURL(blob); | |
| previewImage.onload = function() { | |
| updateImageInfo(); | |
| zoomInBtn.disabled = false; | |
| zoomOutBtn.disabled = false; | |
| const targetPage = Math.floor(index / RESULTS_PER_PAGE) + 1; | |
| if (currentPage !== targetPage) { | |
| currentPage = targetPage; | |
| displayResultsPage(currentPage); | |
| } | |
| document.querySelectorAll('.results-table tr').forEach(r => r.classList.remove('selected')); | |
| const rows = Array.from(resultsTableBody.querySelectorAll('tr')); | |
| const targetRow = rows.find(row => parseInt(row.dataset.originalIndex, 10) === index); | |
| if (targetRow) { | |
| targetRow.classList.add('selected'); | |
| } | |
| resetPanZoom(); | |
| }; | |
| previewImage.src = objectUrl; | |
| previewImage.alt = uuid; | |
| } else { | |
| console.error((isCompleted ? 'Annotate' : 'Preview') + ' image fetch failed:', response.status); | |
| clearPreview(); | |
| } | |
| } catch (e) { | |
| clearPreview(); | |
| } | |
| prevBtn.disabled = index <= 0; | |
| nextBtn.disabled = index >= (currentResults.length > 0 ? currentResults.length : filteredValidFiles.length) - 1; | |
| } | |
| // --- New Polling Function --- | |
| function pollProgress(sessionId) { | |
| if (progressInterval) { | |
| clearInterval(progressInterval); // Clear any existing timer | |
| } | |
| progressInterval = setInterval(async () => { | |
| try { | |
| const response = await fetch(`/progress`); | |
| if (!response.ok) { | |
| let errorText = `Progress check failed: ${response.status}`; | |
| try { | |
| const errorData = await response.json(); | |
| errorText += `: ${errorData.error || 'Unknown progress error'}`; | |
| } catch(e) { errorText += ` - ${response.statusText}`; } | |
| throw new Error(errorText); | |
| } | |
| const data = await response.json(); | |
| const status = (data.status || '').toLowerCase(); | |
| switch (status) { | |
| case 'starting': | |
| updateProgress(data.progress || 0, 'Starting...'); | |
| logStatus('Job is starting.'); | |
| break; | |
| case 'processing': | |
| updateProgress(data.progress || 0, `Processing (${data.progress || 0}%)`); | |
| // If results are present, update detections and table | |
| if (data.results) { | |
| allDetections = Object.entries(data.results).map(([uuid, detections]) => ({ uuid, detections })); | |
| updateResultsTable(); | |
| } | |
| break; | |
| case 'completed': | |
| clearInterval(progressInterval); | |
| progressInterval = null; | |
| updateProgress(100, 'Processing complete'); | |
| logStatus('Processing finished successfully.'); | |
| if (data.results) { | |
| allDetections = Object.entries(data.results).map(([uuid, detections]) => ({ uuid, detections })); | |
| updateResultsTable(); | |
| } | |
| renderConfidencePlot(); | |
| setLoading(false); | |
| break; | |
| case 'error': | |
| clearInterval(progressInterval); | |
| progressInterval = null; | |
| logStatus(`Error during processing: ${data.error || 'Unknown error'}`); | |
| updateProgress(data.progress || 100, 'Error'); | |
| setLoading(false); | |
| break; | |
| case 'unknown': | |
| clearInterval(progressInterval); | |
| progressInterval = null; | |
| logStatus('Unknown job status. Stopping progress updates.'); | |
| updateProgress(data.progress || 0, 'Unknown status'); | |
| setLoading(false); | |
| break; | |
| default: | |
| logStatus(`Status: ${data.status}`); | |
| updateProgress(data.progress || 0, data.status ? data.status.charAt(0).toUpperCase() + data.status.slice(1) : ''); | |
| break; | |
| } | |
| } catch (error) { | |
| clearInterval(progressInterval); | |
| progressInterval = null; | |
| logStatus(`Error polling progress: ${error.message}`); | |
| updateProgress(0, 'Polling Error'); | |
| setLoading(false); | |
| } | |
| }, 1000); // Poll every 1 second | |
| } | |
| // --- UI Update Functions --- | |
| function setLoading(isLoading) { | |
| startProcessingBtn.disabled = isLoading; | |
| if (isLoading) { | |
| startProcessingBtn.innerHTML = '<i class="ri-loader-4-line"></i> Processing...'; | |
| document.body.classList.add('processing'); | |
| // Disable input changes during processing | |
| inputMode.disabled = true; | |
| fileInput.disabled = true; | |
| confidenceSlider.disabled = true; | |
| } else { | |
| startProcessingBtn.innerHTML = '<i class="ri-play-line"></i> Start Processing'; | |
| document.body.classList.remove('processing'); | |
| // Re-enable inputs after processing | |
| inputMode.disabled = false; | |
| fileInput.disabled = false; | |
| confidenceSlider.disabled = false; | |
| } | |
| } | |
| function updateProgress(value, message) { | |
| progress.value = value; | |
| progressPercentage.textContent = `${value}%`; | |
| progressText.textContent = message; | |
| // Clear the image counter when not processing | |
| if (message === 'Please upload an image' || message === 'Processing complete' || message.startsWith('Error')) { | |
| imageCounter.textContent = ''; | |
| } | |
| } | |
| function logStatus(message) { | |
| const timestamp = new Date().toLocaleTimeString(); | |
| statusOutput.innerHTML += `[${timestamp}] ${message}\n`; | |
| statusOutput.scrollTop = statusOutput.scrollHeight; | |
| } | |
| // Add click handlers for sortable columns | |
| document.querySelectorAll('.results-table th[data-sort]').forEach(header => { | |
| header.addEventListener('click', () => { | |
| const field = header.dataset.sort; | |
| if (currentSortField === field) { | |
| currentSortDirection = currentSortDirection === 'asc' ? 'desc' : 'asc'; | |
| } else { | |
| currentSortField = field; | |
| currentSortDirection = 'asc'; | |
| } | |
| // Update sort indicators | |
| document.querySelectorAll('.results-table th[data-sort]').forEach(h => { | |
| h.classList.remove('sort-asc', 'sort-desc'); | |
| }); | |
| header.classList.add(`sort-${currentSortDirection}`); | |
| // Sort and redisplay results | |
| sortResults(); | |
| displayResultsPage(currentPage); | |
| }); | |
| }); | |
| function sortResults() { | |
| if (!currentSortField) return; | |
| currentResults.sort((a, b) => { | |
| let aVal = a[currentSortField]; | |
| let bVal = b[currentSortField]; | |
| // Handle numeric sorting for num_eggs | |
| if (currentSortField === 'num_eggs') { | |
| aVal = parseInt(aVal) || 0; | |
| bVal = parseInt(bVal) || 0; | |
| } | |
| if (aVal < bVal) return currentSortDirection === 'asc' ? -1 : 1; | |
| if (aVal > bVal) return currentSortDirection === 'asc' ? 1 : -1; | |
| return 0; | |
| }); | |
| } | |
| // Results Display | |
| function displayResults(jobStatus, filenameMap, resultsObj) { | |
| resultsTableBody.innerHTML = ''; | |
| currentImageIndex = -1; | |
| currentSortField = null; | |
| currentSortDirection = 'asc'; | |
| // If job is not completed, show filenames only | |
| if (jobStatus !== 'completed') { | |
| Object.entries(filenameMap).forEach(([uuid, originalFilename], idx) => { | |
| const row = resultsTableBody.insertRow(); | |
| row.innerHTML = `<td>${originalFilename}</td><td style="color:#bbb;">NA</td>`; | |
| }); | |
| exportCsvBtn.disabled = true; | |
| exportImagesBtn.disabled = true; | |
| logStatus('Waiting for job to complete...'); | |
| return; | |
| } | |
| // If job is completed, show filtered detection counts | |
| if (resultsObj) { | |
| Object.entries(resultsObj).forEach(([uuid, detections], idx) => { | |
| // Filter by confidence threshold | |
| const threshold = parseFloat(confidenceSlider.value); | |
| const filtered = detections.filter(d => d.score >= threshold); | |
| const originalFilename = filenameMap[uuid] || uuid; | |
| const row = resultsTableBody.insertRow(); | |
| row.innerHTML = `<td>${originalFilename}</td><td>${filtered.length}</td>`; | |
| }); | |
| exportCsvBtn.disabled = false; | |
| exportImagesBtn.disabled = false; | |
| logStatus('Job completed. Results displayed.'); | |
| } else { | |
| logStatus('No results found.'); | |
| } | |
| } | |
| // Display a specific page of results | |
| function displayResultsPage(page) { | |
| resultsTableBody.innerHTML = ''; | |
| // Calculate start and end indices for current page | |
| const startIndex = (page - 1) * RESULTS_PER_PAGE; | |
| const endIndex = Math.min(startIndex + RESULTS_PER_PAGE, currentResults.length); | |
| // Display results for current page | |
| for (let i = startIndex; i < endIndex; i++) { | |
| const result = currentResults[i]; | |
| const originalFilename = filenameMap[result.uuid] || result.uuid; | |
| const row = resultsTableBody.insertRow(); | |
| row.innerHTML = ` | |
| <td> | |
| <i class="ri-image-line"></i> | |
| ${originalFilename} | |
| </td> | |
| <td>${result.num_eggs}</td> | |
| <td class="text-right"> | |
| <button class="view-button" data-index="${i}" title="Click to view image"> | |
| <i class="ri-eye-line"></i> | |
| View | |
| </button> | |
| </td> | |
| `; | |
| // Store the original index to maintain image preview relationship | |
| row.dataset.originalIndex = i; | |
| } | |
| // Wire up View buttons after rows are created | |
| resultsTableBody.querySelectorAll('.view-button').forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| const idx = parseInt(btn.getAttribute('data-index')); | |
| displayImage(idx); | |
| // Highlight selected row | |
| resultsTableBody.querySelectorAll('tr').forEach(r => r.classList.remove('selected')); | |
| btn.closest('tr').classList.add('selected'); | |
| }); | |
| }); | |
| // Update pagination UI | |
| updatePaginationControls(); | |
| } | |
| // Update pagination controls with enhanced display | |
| function updatePaginationControls() { | |
| const paginationContainer = document.getElementById('pagination-controls'); | |
| if (!paginationContainer) return; | |
| paginationContainer.innerHTML = ''; | |
| if (totalPages <= 1) return; | |
| const controls = document.createElement('div'); | |
| controls.className = 'pagination-controls'; | |
| // Previous button | |
| const prevButton = document.createElement('button'); | |
| prevButton.innerHTML = '<i class="ri-arrow-left-s-line"></i>'; | |
| prevButton.className = 'pagination-btn'; | |
| prevButton.disabled = currentPage === 1; | |
| prevButton.addEventListener('click', () => { | |
| if (currentPage > 1) { | |
| currentPage--; | |
| displayResultsPage(currentPage); | |
| } | |
| }); | |
| controls.appendChild(prevButton); | |
| // Page numbers with ellipsis | |
| const addPageButton = (pageNum) => { | |
| const button = document.createElement('button'); | |
| button.textContent = pageNum; | |
| button.className = 'pagination-btn' + (pageNum === currentPage ? ' active' : ''); | |
| button.addEventListener('click', () => { | |
| currentPage = pageNum; | |
| displayResultsPage(currentPage); | |
| }); | |
| controls.appendChild(button); | |
| }; | |
| const addEllipsis = () => { | |
| const span = document.createElement('span'); | |
| span.className = 'pagination-ellipsis'; | |
| span.textContent = '...'; | |
| controls.appendChild(span); | |
| }; | |
| // First page | |
| addPageButton(1); | |
| // Calculate visible page range | |
| if (totalPages > 7) { | |
| if (currentPage > 3) { | |
| addEllipsis(); | |
| } | |
| for (let i = Math.max(2, currentPage - 1); i <= Math.min(currentPage + 1, totalPages - 1); i++) { | |
| addPageButton(i); | |
| } | |
| if (currentPage < totalPages - 2) { | |
| addEllipsis(); | |
| } | |
| } else { | |
| // Show all pages if total pages is small | |
| for (let i = 2; i < totalPages; i++) { | |
| addPageButton(i); | |
| } | |
| } | |
| // Last page if not already added | |
| if (totalPages > 1) { | |
| addPageButton(totalPages); | |
| } | |
| // Next button | |
| const nextButton = document.createElement('button'); | |
| nextButton.innerHTML = '<i class="ri-arrow-right-s-line"></i>'; | |
| nextButton.className = 'pagination-btn'; | |
| nextButton.disabled = currentPage === totalPages; | |
| nextButton.addEventListener('click', () => { | |
| if (currentPage < totalPages) { | |
| currentPage++; | |
| displayResultsPage(currentPage); | |
| } | |
| }); | |
| controls.appendChild(nextButton); | |
| // Add page info | |
| const pageInfo = document.createElement('div'); | |
| pageInfo.className = 'pagination-info'; | |
| pageInfo.textContent = `Page ${currentPage} of ${totalPages}`; | |
| // Add both controls and info to container | |
| paginationContainer.appendChild(controls); | |
| paginationContainer.appendChild(pageInfo); | |
| } | |
| // Image Preview | |
| function clearPreview() { | |
| previewImage.src = ''; | |
| previewImage.alt = 'No image selected'; | |
| imageInfo.textContent = 'Select an image from the results to view'; | |
| currentImageIndex = -1; | |
| resetPanZoom(); | |
| // Disable controls | |
| prevBtn.disabled = true; | |
| nextBtn.disabled = true; | |
| zoomInBtn.disabled = true; | |
| zoomOutBtn.disabled = true; | |
| } | |
| function resetPanZoom() { | |
| // Reset zoom and pan values | |
| currentZoomLevel = 1; | |
| currentPanX = 0; | |
| currentPanY = 0; | |
| // Reset transform directly | |
| if (previewImage.src) { | |
| previewImage.style.transform = 'none'; | |
| // Force a reflow to ensure the transform is actually reset | |
| void previewImage.offsetWidth; | |
| // Then apply the default transform | |
| updateImageTransform(); | |
| } | |
| updatePanCursorState(); | |
| } | |
| // Image Navigation with debouncing | |
| let navigationInProgress = false; | |
| prevBtn.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| if (!navigationInProgress && currentImageIndex > 0) { | |
| navigationInProgress = true; | |
| displayImage(currentImageIndex - 1); | |
| setTimeout(() => { navigationInProgress = false; }, 300); | |
| } | |
| }); | |
| nextBtn.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| if (!navigationInProgress && currentImageIndex < currentResults.length - 1) { | |
| navigationInProgress = true; | |
| displayImage(currentImageIndex + 1); | |
| setTimeout(() => { navigationInProgress = false; }, 300); | |
| } | |
| }); | |
| // Zoom Controls | |
| function updateImageTransform() { | |
| if (previewImage.src) { | |
| previewImage.style.transform = `translate(${currentPanX}px, ${currentPanY}px) scale(${currentZoomLevel})`; | |
| } | |
| } | |
| // Unified zoom function for both buttons and wheel | |
| function zoomImage(newZoom, zoomX, zoomY) { | |
| if (newZoom >= MIN_ZOOM && newZoom <= MAX_ZOOM) { | |
| const oldZoom = currentZoomLevel; | |
| // If zooming out to minimum, just reset everything | |
| if (newZoom === MIN_ZOOM) { | |
| currentZoomLevel = MIN_ZOOM; | |
| currentPanX = 0; | |
| currentPanY = 0; | |
| } else { | |
| // Calculate zoom ratio | |
| const zoomRatio = newZoom / oldZoom; | |
| // Get the image and container dimensions | |
| const containerRect = imageContainer.getBoundingClientRect(); | |
| const imgRect = previewImage.getBoundingClientRect(); | |
| // Calculate the center of the image | |
| const imgCenterX = imgRect.width / 2; | |
| const imgCenterY = imgRect.height / 2; | |
| // Calculate the point to zoom relative to | |
| const relativeX = zoomX - (imgRect.left - containerRect.left); | |
| const relativeY = zoomY - (imgRect.top - containerRect.top); | |
| // Update zoom level | |
| currentZoomLevel = newZoom; | |
| // Adjust pan to maintain the zoom point position | |
| currentPanX = currentPanX + (imgCenterX - relativeX) * (zoomRatio - 1); | |
| currentPanY = currentPanY + (imgCenterY - relativeY) * (zoomRatio - 1); | |
| } | |
| updateImageTransform(); | |
| updatePanCursorState(); | |
| updateImageInfo(); | |
| } | |
| } | |
| // Zoom in button handler | |
| zoomInBtn.addEventListener('click', () => { | |
| if (currentZoomLevel < MAX_ZOOM && previewImage.src) { | |
| const containerRect = imageContainer.getBoundingClientRect(); | |
| const imgRect = previewImage.getBoundingClientRect(); | |
| // Calculate the center point of the image | |
| const centerX = (imgRect.left - containerRect.left) + imgRect.width / 2; | |
| const centerY = (imgRect.top - containerRect.top) + imgRect.height / 2; | |
| zoomImage(currentZoomLevel + 0.25, centerX, centerY); | |
| } | |
| }); | |
| // Zoom out button handler | |
| zoomOutBtn.addEventListener('click', () => { | |
| if (currentZoomLevel > MIN_ZOOM && previewImage.src) { | |
| const containerRect = imageContainer.getBoundingClientRect(); | |
| const imgRect = previewImage.getBoundingClientRect(); | |
| // Calculate the center point of the image | |
| const centerX = (imgRect.left - containerRect.left) + imgRect.width / 2; | |
| const centerY = (imgRect.top - containerRect.top) + imgRect.height / 2; | |
| zoomImage(currentZoomLevel - 0.25, centerX, centerY); | |
| } | |
| }); | |
| // Mouse wheel zoom uses cursor position | |
| imageContainer.addEventListener('wheel', (e) => { | |
| if (previewImage.src) { | |
| e.preventDefault(); | |
| // Get mouse position relative to container | |
| const rect = imageContainer.getBoundingClientRect(); | |
| const mouseX = e.clientX - rect.left; | |
| const mouseY = e.clientY - rect.top; | |
| // Calculate zoom direction and factor | |
| const zoomDirection = e.deltaY < 0 ? 1 : -1; | |
| const zoomFactor = 0.1; | |
| // Calculate and apply new zoom level | |
| const newZoom = currentZoomLevel + (zoomDirection * zoomFactor); | |
| zoomImage(newZoom, mouseX, mouseY); | |
| } | |
| }); | |
| // Panning event listeners | |
| imageContainer.addEventListener('mousedown', (e) => { | |
| if (e.button === 0 && previewImage.src && currentZoomLevel > 1) { // Left mouse button and zoomed in | |
| isPanning = true; | |
| startPanX = e.clientX - currentPanX; | |
| startPanY = e.clientY - currentPanY; | |
| imageContainer.classList.add('panning'); | |
| e.preventDefault(); // Prevent image dragging behavior | |
| } | |
| }); | |
| window.addEventListener('mousemove', (e) => { | |
| if (isPanning) { | |
| currentPanX = e.clientX - startPanX; | |
| currentPanY = e.clientY - startPanY; | |
| updateImageTransform(); | |
| } | |
| }); | |
| window.addEventListener('mouseup', () => { | |
| if (isPanning) { | |
| isPanning = false; | |
| imageContainer.classList.remove('panning'); | |
| } | |
| }); | |
| // Function to update cursor state based on zoom level | |
| function updatePanCursorState() { | |
| if (currentZoomLevel > 1) { | |
| imageContainer.classList.add('can-pan'); | |
| } else { | |
| imageContainer.classList.remove('can-pan'); | |
| imageContainer.classList.remove('panning'); | |
| } | |
| } | |
| // --- Export Handlers (updated for new workflow) --- | |
| exportCsvBtn.addEventListener('click', async () => { | |
| const threshold = parseFloat(confidenceSlider.value); | |
| const timestamp = new Date().toISOString().replace(/[-:T.]/g, '').slice(0, 15); | |
| const defaultName = `nemaquant_results_${timestamp}.csv`; | |
| try { | |
| const resp = await fetch('/export_csv', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ confidence: threshold }) | |
| }); | |
| if (!resp.ok) throw new Error('Failed to export CSV'); | |
| const blob = await resp.blob(); | |
| if ('showSaveFilePicker' in window) { | |
| // File System Access API | |
| const handle = await window.showSaveFilePicker({ | |
| suggestedName: defaultName, | |
| types: [{ description: 'CSV', accept: { 'text/csv': ['.csv'] } }] | |
| }); | |
| const writable = await handle.createWritable(); | |
| await writable.write(blob); | |
| await writable.close(); | |
| } else { | |
| // Fallback: download link | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = defaultName; | |
| document.body.appendChild(a); | |
| a.click(); | |
| setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 1000); | |
| } | |
| } catch (err) { | |
| logStatus('Failed to export CSV: ' + err.message, true); | |
| } | |
| }); | |
| exportImagesBtn.addEventListener('click', async () => { | |
| const threshold = parseFloat(confidenceSlider.value); | |
| const timestamp = new Date().toISOString().replace(/[-:T.]/g, '').slice(0, 15); | |
| const defaultName = `nemaquant_annotated_${timestamp}.zip`; | |
| try { | |
| logStatus('Preparing annotated images for download...'); | |
| const resp = await fetch('/export_images', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ confidence: threshold }) | |
| }); | |
| if (!resp.ok) throw new Error('Failed to export images'); | |
| const blob = await resp.blob(); | |
| if ('showSaveFilePicker' in window) { | |
| const handle = await window.showSaveFilePicker({ | |
| suggestedName: defaultName, | |
| types: [{ description: 'ZIP', accept: { 'application/zip': ['.zip'] } }] | |
| }); | |
| const writable = await handle.createWritable(); | |
| await writable.write(blob); | |
| await writable.close(); | |
| } else { | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = defaultName; | |
| document.body.appendChild(a); | |
| a.click(); | |
| setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 1000); | |
| } | |
| } catch (err) { | |
| logStatus('Failed to export images: ' + err.message, true); | |
| } | |
| }); | |
| // Add keyboard controls for panning (arrow keys) | |
| window.addEventListener('keydown', (e) => { | |
| if (previewImage.src && currentZoomLevel > 1) { | |
| const PAN_AMOUNT = 30; // pixels to pan per key press | |
| switch (e.key) { | |
| case 'ArrowLeft': | |
| currentPanX += PAN_AMOUNT; | |
| updateImageTransform(); | |
| e.preventDefault(); | |
| break; | |
| case 'ArrowRight': | |
| currentPanX -= PAN_AMOUNT; | |
| updateImageTransform(); | |
| e.preventDefault(); | |
| break; | |
| case 'ArrowUp': | |
| currentPanY += PAN_AMOUNT; | |
| updateImageTransform(); | |
| e.preventDefault(); | |
| break; | |
| case 'ArrowDown': | |
| currentPanY -= PAN_AMOUNT; | |
| updateImageTransform(); | |
| e.preventDefault(); | |
| break; | |
| case 'Home': | |
| // Reset pan position but keep zoom | |
| currentPanX = 0; | |
| currentPanY = 0; | |
| updateImageTransform(); | |
| e.preventDefault(); | |
| break; | |
| } | |
| } | |
| }); | |
| // Add some instructions to the image info when zoomed in | |
| function updateImageInfo() { | |
| if (!currentResults[currentImageIndex]) return; | |
| const result = currentResults[currentImageIndex]; | |
| let infoText = ` | |
| <i class="ri-image-line"></i> ${result.filename} | |
| <br> | |
| <i class="ri-egg-line"></i> ${result.num_eggs} eggs detected | |
| `; | |
| if (currentZoomLevel > 1) { | |
| infoText += ` | |
| <br> | |
| <small style="color: var(--text-muted);"> | |
| <i class="ri-drag-move-line"></i> Click and drag to pan or use arrow keys | |
| </small> | |
| `; | |
| } | |
| imageInfo.innerHTML = infoText; | |
| } | |
| // --- Plotly Dot Plot for Confidence Threshold --- | |
| function renderConfidencePlot() { | |
| const plotDiv = document.getElementById('confidence-plot'); | |
| if (!allDetections || allDetections.length === 0) { | |
| Plotly.purge(plotDiv); | |
| plotDiv.style.display = 'none'; | |
| return; | |
| } | |
| plotDiv.style.display = ''; | |
| // Aggregate all detections for all images | |
| const bins = []; | |
| for (let x = 0.05; x <= 0.951; x += 0.05) { | |
| const conf = x; | |
| let count = 0; | |
| allDetections.forEach(imgResult => { | |
| count += imgResult.detections.filter(det => det.score >= conf).length; | |
| }); | |
| bins.push({conf, count}); | |
| } | |
| const xVals = bins.map(b => b.conf); | |
| const yVals = bins.map(b => b.count); | |
| const currentConf = parseFloat(confidenceSlider.value); | |
| // Split points and lines by cutoff | |
| const leftX = [], leftY = []; | |
| const rightX = [], rightY = []; | |
| for (let i = 0; i < xVals.length; i++) { | |
| if (xVals[i] <= currentConf) { | |
| leftX.push(xVals[i]); | |
| leftY.push(yVals[i]); | |
| } else { | |
| rightX.push(xVals[i]); | |
| rightY.push(yVals[i]); | |
| } | |
| } | |
| // For line segments, we need to split at the cutoff if necessary | |
| let splitIndex = xVals.findIndex(x => x > currentConf); | |
| if (splitIndex === -1) splitIndex = xVals.length; | |
| // If the cutoff is between two points, interpolate a point at the cutoff for smooth color transition | |
| let interpX = null, interpY = null; | |
| if (splitIndex > 0 && splitIndex < xVals.length) { | |
| const x0 = xVals[splitIndex - 1], x1 = xVals[splitIndex]; | |
| const y0 = yVals[splitIndex - 1], y1 = yVals[splitIndex]; | |
| const t = (currentConf - x0) / (x1 - x0); | |
| interpX = currentConf; | |
| interpY = y0 + t * (y1 - y0); | |
| } | |
| // Blue trace (left of or on cutoff) | |
| const blueTrace = { | |
| x: interpX !== null ? [...leftX, interpX] : leftX, | |
| y: interpY !== null ? [...leftY, interpY] : leftY, | |
| mode: 'markers+lines', | |
| marker: {size: 8, color: '#2563eb'}, | |
| line: {shape: 'linear', color: '#2563eb'}, | |
| type: 'scatter', | |
| hoverinfo: 'x+y', | |
| showlegend: false | |
| }; | |
| // Grey trace (right of cutoff) | |
| const greyTrace = { | |
| x: interpX !== null ? [interpX, ...rightX] : rightX, | |
| y: interpY !== null ? [interpY, ...rightY] : rightY, | |
| mode: 'markers+lines', | |
| marker: {size: 8, color: '#bbb'}, | |
| line: {shape: 'linear', color: '#bbb'}, | |
| type: 'scatter', | |
| hoverinfo: 'x+y', | |
| showlegend: false | |
| }; | |
| // Vertical line as a trace (above grid, below data) | |
| const vlineTrace = { | |
| x: [currentConf, currentConf], | |
| y: [0, Math.max(...yVals, 1)], | |
| mode: 'lines', | |
| line: {color: 'red', width: 2, dash: 'dot'}, | |
| hoverinfo: 'skip', | |
| showlegend: false | |
| }; | |
| const layout = { | |
| margin: {t: 20, r: 20, l: 40, b: 40}, | |
| xaxis: {title: 'Threshold', dtick: 0.1, range: [0, 1], automargin: true, title_standoff: 18}, | |
| yaxis: {title: 'Total Eggs Detected', rangemode: 'tozero', automargin: true, title_standoff: 18}, | |
| showlegend: false, | |
| height: 320 | |
| }; | |
| Plotly.newPlot('confidence-plot', [vlineTrace, greyTrace, blueTrace], layout, { | |
| displayModeBar: false, | |
| responsive: true, | |
| staticPlot: true // disables zoom/pan/drag | |
| }); | |
| // Update total eggs detected text | |
| const totalEggs = allDetections.reduce((sum, imgResult) => sum + imgResult.detections.filter(det => det.score >= currentConf).length, 0); | |
| const totalEggsElem = document.getElementById('total-eggs-count'); | |
| if (totalEggsElem) totalEggsElem.textContent = totalEggs; | |
| } | |
| // Call this after processing completes | |
| function onProcessingComplete() { | |
| renderConfidencePlot(); | |
| } | |
| // Custom dialog for selecting among multiple CSV files | |
| function showCsvSelectionDialog(csvFiles) { | |
| // Create overlay | |
| const overlay = document.createElement('div'); | |
| overlay.style.position = 'fixed'; | |
| overlay.style.top = 0; | |
| overlay.style.left = 0; | |
| overlay.style.width = '100vw'; | |
| overlay.style.height = '100vh'; | |
| overlay.style.background = 'rgba(0,0,0,0.4)'; | |
| overlay.style.zIndex = 9999; | |
| overlay.style.display = 'flex'; | |
| overlay.style.alignItems = 'center'; | |
| overlay.style.justifyContent = 'center'; | |
| // Create dialog box | |
| const dialog = document.createElement('div'); | |
| dialog.style.background = '#fff'; | |
| dialog.style.padding = '24px 32px'; | |
| dialog.style.borderRadius = '8px'; | |
| dialog.style.boxShadow = '0 2px 16px rgba(0,0,0,0.2)'; | |
| dialog.style.minWidth = '320px'; | |
| dialog.innerHTML = `<h3>Select a CSV key file</h3><p>Multiple CSV files were found. Please select one to use as the key file:</p>`; | |
| // List CSV files as buttons | |
| csvFiles.forEach((file, idx) => { | |
| const btn = document.createElement('button'); | |
| btn.textContent = file.name; | |
| btn.style.display = 'block'; | |
| btn.style.margin = '8px 0'; | |
| btn.onclick = () => { | |
| window.selectedKeyenceCsv = file; | |
| logStatus(`CSV key file selected: ${file.name}`); | |
| document.body.removeChild(overlay); | |
| }; | |
| dialog.appendChild(btn); | |
| }); | |
| // Option: Use a different .csv | |
| const otherBtn = document.createElement('button'); | |
| otherBtn.textContent = 'Use a different .csv'; | |
| otherBtn.style.display = 'block'; | |
| otherBtn.style.margin = '16px 0 8px 0'; | |
| otherBtn.onclick = () => { | |
| let csvInput = document.createElement('input'); | |
| csvInput.type = 'file'; | |
| csvInput.accept = '.csv,.CSV'; | |
| csvInput.style.display = 'none'; | |
| document.body.appendChild(csvInput); | |
| csvInput.addEventListener('change', function() { | |
| if (csvInput.files && csvInput.files.length === 1) { | |
| window.selectedKeyenceCsv = csvInput.files[0]; | |
| logStatus(`CSV key file supplied: ${csvInput.files[0].name}`); | |
| } else { | |
| window.selectedKeyenceCsv = null; | |
| logStatus('No CSV key file supplied.'); | |
| } | |
| document.body.removeChild(csvInput); | |
| document.body.removeChild(overlay); | |
| }); | |
| csvInput.click(); | |
| }; | |
| dialog.appendChild(otherBtn); | |
| // Option: No key file | |
| const noneBtn = document.createElement('button'); | |
| noneBtn.textContent = 'No key file'; | |
| noneBtn.style.display = 'block'; | |
| noneBtn.style.margin = '8px 0'; | |
| noneBtn.onclick = () => { | |
| window.selectedKeyenceCsv = null; | |
| logStatus('No CSV key file will be used.'); | |
| document.body.removeChild(overlay); | |
| }; | |
| dialog.appendChild(noneBtn); | |
| // Cancel button | |
| const cancelBtn = document.createElement('button'); | |
| cancelBtn.textContent = 'Cancel'; | |
| cancelBtn.style.display = 'block'; | |
| cancelBtn.style.margin = '8px 0'; | |
| cancelBtn.onclick = () => { | |
| // Do not change selection, just close dialog | |
| logStatus('CSV key file selection cancelled.'); | |
| document.body.removeChild(overlay); | |
| }; | |
| dialog.appendChild(cancelBtn); | |
| overlay.appendChild(dialog); | |
| document.body.appendChild(overlay); | |
| } | |
| // Custom dialog for single CSV file found | |
| function showSingleCsvDialog(csvFile) { | |
| const overlay = document.createElement('div'); | |
| overlay.style.position = 'fixed'; | |
| overlay.style.top = 0; | |
| overlay.style.left = 0; | |
| overlay.style.width = '100vw'; | |
| overlay.style.height = '100vh'; | |
| overlay.style.background = 'rgba(0,0,0,0.4)'; | |
| overlay.style.zIndex = 9999; | |
| overlay.style.display = 'flex'; | |
| overlay.style.alignItems = 'center'; | |
| overlay.style.justifyContent = 'center'; | |
| const dialog = document.createElement('div'); | |
| dialog.style.background = '#fff'; | |
| dialog.style.padding = '24px 32px'; | |
| dialog.style.borderRadius = '8px'; | |
| dialog.style.boxShadow = '0 2px 16px rgba(0,0,0,0.2)'; | |
| dialog.style.minWidth = '320px'; | |
| dialog.innerHTML = `<h3>CSV File Found</h3><p>A CSV file named <strong>${csvFile.name}</strong> was found in the folder. Would you like to use this as the key file for this folder?</p>`; | |
| // Yes button | |
| const yesBtn = document.createElement('button'); | |
| yesBtn.textContent = 'Yes'; | |
| yesBtn.style.display = 'block'; | |
| yesBtn.style.margin = '8px 0'; | |
| yesBtn.onclick = () => { | |
| window.selectedKeyenceCsv = csvFile; | |
| logStatus(`CSV key file selected: ${csvFile.name}`); | |
| document.body.removeChild(overlay); | |
| }; | |
| dialog.appendChild(yesBtn); | |
| // Use a different .csv file | |
| const otherBtn = document.createElement('button'); | |
| otherBtn.textContent = 'Use a different .csv file'; | |
| otherBtn.style.display = 'block'; | |
| otherBtn.style.margin = '8px 0'; | |
| otherBtn.onclick = () => { | |
| let csvInput = document.createElement('input'); | |
| csvInput.type = 'file'; | |
| csvInput.accept = '.csv,.CSV'; | |
| csvInput.style.display = 'none'; | |
| document.body.appendChild(csvInput); | |
| csvInput.addEventListener('change', function() { | |
| if (csvInput.files && csvInput.files.length === 1) { | |
| window.selectedKeyenceCsv = csvInput.files[0]; | |
| logStatus(`CSV key file supplied: ${csvInput.files[0].name}`); | |
| } else { | |
| window.selectedKeyenceCsv = null; | |
| logStatus('No CSV key file supplied.'); | |
| } | |
| document.body.removeChild(csvInput); | |
| document.body.removeChild(overlay); | |
| }); | |
| csvInput.click(); | |
| }; | |
| dialog.appendChild(otherBtn); | |
| // Do not use a key file | |
| const noneBtn = document.createElement('button'); | |
| noneBtn.textContent = 'Do not use a key file'; | |
| noneBtn.style.display = 'block'; | |
| noneBtn.style.margin = '8px 0'; | |
| noneBtn.onclick = () => { | |
| window.selectedKeyenceCsv = null; | |
| logStatus('No CSV key file will be used.'); | |
| document.body.removeChild(overlay); | |
| }; | |
| dialog.appendChild(noneBtn); | |
| // Cancel button | |
| const cancelBtn = document.createElement('button'); | |
| cancelBtn.textContent = 'Cancel'; | |
| cancelBtn.style.display = 'block'; | |
| cancelBtn.style.margin = '8px 0'; | |
| cancelBtn.onclick = () => { | |
| // Do not change selection, just close dialog | |
| logStatus('CSV key file selection cancelled.'); | |
| document.body.removeChild(overlay); | |
| }; | |
| dialog.appendChild(cancelBtn); | |
| overlay.appendChild(dialog); | |
| document.body.appendChild(overlay); | |
| } | |
| // Initialize | |
| inputMode.selectedIndex = 0; // Reset inputMode to default (first option) | |
| updateUploadState(); | |
| logStatus('Application ready'); | |
| // Hide plot on page load and after clearing files | |
| document.getElementById('confidence-plot').style.display = 'none'; | |
| // Reset confidence slider to 0.6 and force UI update on page load | |
| confidenceSlider.value = 0.6; | |
| confidenceSlider.dispatchEvent(new Event('input', { bubbles: true })); | |
| confidenceSlider.dispatchEvent(new Event('change', { bubbles: true })); | |
| }); |