Spaces:
Sleeping
Sleeping
Pokkhrong Rasee
Deploy Image Captioning Web App with FastAPI and Docker to Hugging Face Space
d1d7665 | /** | |
| * app.js — Robust Frontend logic for Dual Model Image Captioning Web App | |
| */ | |
| // Helper to get DOM elements safely | |
| const getElem = (id) => document.getElementById(id); | |
| let currentFile = null; | |
| // ============================================================ | |
| // CHECK MODEL STATUS | |
| // ============================================================ | |
| async function checkModelStatus() { | |
| const modelBadge = getElem('model-badge'); | |
| if (!modelBadge) return; | |
| try { | |
| const res = await fetch('/api/status'); | |
| const data = await res.json(); | |
| modelBadge.classList.remove('hidden'); | |
| if (data.status === 'ready') { | |
| const hasFt = data.has_finetuned ? 'Fine-Tuned & Pretrained Available' : 'Pretrained Available'; | |
| modelBadge.textContent = `⚡ Status: Ready (${hasFt}) | Device: ${data.device}`; | |
| modelBadge.className = 'model-badge ready'; | |
| } else { | |
| modelBadge.textContent = '⏳ Models loading...'; | |
| modelBadge.className = 'model-badge loading'; | |
| } | |
| } catch (e) { | |
| console.error('Status check failed:', e); | |
| } | |
| } | |
| // Get selected model mode | |
| function getSelectedModelChoice() { | |
| const radio = document.querySelector('input[name="model_choice"]:checked'); | |
| return radio ? radio.value : 'both'; | |
| } | |
| // Get selected language choice | |
| function getSelectedLangChoice() { | |
| const radio = document.querySelector('input[name="lang_choice"]:checked'); | |
| return radio ? radio.value : 'both'; | |
| } | |
| // ============================================================ | |
| // UI VISIBILITY HELPERS | |
| // ============================================================ | |
| function showResultArea() { | |
| const dropZone = getElem('drop-zone'); | |
| const resultArea = getElem('result-area'); | |
| const resultsGrid = getElem('results-grid'); | |
| const loadingSpinner = getElem('loading-spinner'); | |
| if (dropZone) dropZone.classList.add('hidden'); | |
| if (resultArea) resultArea.classList.remove('hidden'); | |
| if (resultsGrid) resultsGrid.innerHTML = ''; | |
| if (loadingSpinner) loadingSpinner.classList.add('active'); | |
| } | |
| function showDropZone() { | |
| const dropZone = getElem('drop-zone'); | |
| const resultArea = getElem('result-area'); | |
| const fileInput = getElem('file-input'); | |
| if (resultArea) resultArea.classList.add('hidden'); | |
| if (dropZone) dropZone.classList.remove('hidden'); | |
| if (fileInput) fileInput.value = ''; | |
| currentFile = null; | |
| hideError(); | |
| } | |
| function showError(message) { | |
| const errorMsg = getElem('error-msg'); | |
| if (errorMsg) { | |
| errorMsg.textContent = message; | |
| errorMsg.classList.remove('hidden'); | |
| } | |
| } | |
| function hideError() { | |
| const errorMsg = getElem('error-msg'); | |
| if (errorMsg) errorMsg.classList.add('hidden'); | |
| } | |
| // ============================================================ | |
| // HANDLE FILE | |
| // ============================================================ | |
| function handleFile(file) { | |
| if (!file.type.startsWith('image/')) { | |
| showError('Please upload an image file (JPEG, PNG, GIF, WebP).'); | |
| return; | |
| } | |
| currentFile = file; | |
| hideError(); | |
| const previewImg = getElem('preview-img'); | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| if (previewImg) previewImg.src = e.target.result; | |
| showResultArea(); | |
| generateCaption(file); | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| // ============================================================ | |
| // GENERATE CAPTION (API CALL) | |
| // ============================================================ | |
| async function generateCaption(file) { | |
| const loadingSpinner = getElem('loading-spinner'); | |
| const resultsGrid = getElem('results-grid'); | |
| const modelChoice = getSelectedModelChoice(); | |
| const langChoice = getSelectedLangChoice(); | |
| const formData = new FormData(); | |
| formData.append('file', file); | |
| formData.append('model_choice', modelChoice); | |
| formData.append('language', langChoice); | |
| if (loadingSpinner) loadingSpinner.classList.add('active'); | |
| if (resultsGrid) resultsGrid.innerHTML = ''; | |
| try { | |
| const res = await fetch('/api/predict', { | |
| method: 'POST', | |
| body: formData, | |
| }); | |
| const data = await res.json(); | |
| if (!res.ok) { | |
| throw new Error(data.detail || 'Inference failed'); | |
| } | |
| if (loadingSpinner) loadingSpinner.classList.remove('active'); | |
| renderResults(data.results, langChoice); | |
| } catch (err) { | |
| if (loadingSpinner) loadingSpinner.classList.remove('active'); | |
| showError(err.message); | |
| console.error('Prediction error:', err); | |
| } | |
| } | |
| // ============================================================ | |
| // RENDER RESULTS GRID | |
| // ============================================================ | |
| function renderResults(results, langChoice = 'both') { | |
| let resultsGrid = getElem('results-grid'); | |
| // Fallback: if results-grid is missing, create it dynamically inside result-area | |
| if (!resultsGrid) { | |
| const resultArea = getElem('result-area'); | |
| if (resultArea) { | |
| resultsGrid = document.createElement('div'); | |
| resultsGrid.id = 'results-grid'; | |
| resultsGrid.className = 'results-grid'; | |
| const clearBtn = getElem('clear-btn'); | |
| if (clearBtn) { | |
| resultArea.insertBefore(resultsGrid, clearBtn); | |
| } else { | |
| resultArea.appendChild(resultsGrid); | |
| } | |
| } | |
| } | |
| if (!resultsGrid) return; | |
| resultsGrid.innerHTML = ''; | |
| if (!results) return; | |
| const keys = Object.keys(results); | |
| const isGrid = keys.length > 1; | |
| if (isGrid) { | |
| resultsGrid.classList.add('dual-grid'); | |
| } else { | |
| resultsGrid.classList.remove('dual-grid'); | |
| } | |
| keys.forEach(key => { | |
| const item = results[key]; | |
| const isFineTuned = key === 'fine-tuned'; | |
| const card = document.createElement('div'); | |
| card.className = `caption-card ${isFineTuned ? 'fine-tuned-card' : 'pretrained-card'}`; | |
| const captions = item.captions || []; | |
| const captionsTh = item.captions_th || []; | |
| const captionsHtml = captions | |
| .map((c, i) => { | |
| const thText = captionsTh[i] || ''; | |
| let contentHtml = ''; | |
| if (langChoice === 'en') { | |
| contentHtml = `<span class="text text-en">${c}</span>`; | |
| } else if (langChoice === 'th') { | |
| contentHtml = `<span class="text text-th">${thText || c}</span>`; | |
| } else { | |
| // Both | |
| contentHtml = ` | |
| <div class="text-group"> | |
| <span class="text text-en">${c}</span> | |
| ${thText ? `<span class="text text-th">🇹🇭 ${thText}</span>` : ''} | |
| </div> | |
| `; | |
| } | |
| return ` | |
| <li class="caption-item"> | |
| <span class="num">${i + 1}</span> | |
| ${contentHtml} | |
| </li> | |
| `; | |
| }) | |
| .join(''); | |
| card.innerHTML = ` | |
| <div class="caption-header"> | |
| <span class="model-title">${item.label}</span> | |
| <span class="tag ${isFineTuned ? 'tag-ft' : 'tag-pre'}">${isFineTuned ? '🎯 Fine-Tuned' : '🌐 Pretrained'}</span> | |
| </div> | |
| <ol class="captions-list"> | |
| ${captionsHtml} | |
| </ol> | |
| `; | |
| resultsGrid.appendChild(card); | |
| }); | |
| } | |
| // ============================================================ | |
| // INITIALIZATION ON DOM READY | |
| // ============================================================ | |
| document.addEventListener('DOMContentLoaded', () => { | |
| checkModelStatus(); | |
| const dropZone = getElem('drop-zone'); | |
| const fileInput = getElem('file-input'); | |
| const clearBtn = getElem('clear-btn'); | |
| if (dropZone) { | |
| dropZone.addEventListener('dragover', (e) => { | |
| e.preventDefault(); | |
| dropZone.classList.add('dragover'); | |
| }); | |
| dropZone.addEventListener('dragleave', () => { | |
| dropZone.classList.remove('dragover'); | |
| }); | |
| dropZone.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| dropZone.classList.remove('dragover'); | |
| const files = e.dataTransfer.files; | |
| if (files.length > 0) { | |
| handleFile(files[0]); | |
| } | |
| }); | |
| dropZone.addEventListener('click', () => { | |
| if (fileInput) fileInput.click(); | |
| }); | |
| } | |
| if (fileInput) { | |
| fileInput.addEventListener('change', (e) => { | |
| if (e.target.files.length > 0) { | |
| handleFile(e.target.files[0]); | |
| } | |
| }); | |
| } | |
| if (clearBtn) { | |
| clearBtn.addEventListener('click', showDropZone); | |
| } | |
| // Re-trigger generation if user switches model choice or language radio buttons while viewing results | |
| const retrigger = () => { | |
| const resultArea = getElem('result-area'); | |
| if (currentFile && resultArea && !resultArea.classList.contains('hidden')) { | |
| generateCaption(currentFile); | |
| } | |
| }; | |
| document.querySelectorAll('input[name="model_choice"]').forEach(radio => { | |
| radio.addEventListener('change', retrigger); | |
| }); | |
| document.querySelectorAll('input[name="lang_choice"]').forEach(radio => { | |
| radio.addEventListener('change', retrigger); | |
| }); | |
| }); | |
| // Global drag behaviors | |
| document.addEventListener('dragover', (e) => e.preventDefault()); | |
| document.addEventListener('drop', (e) => e.preventDefault()); | |