Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>CLIP Batch Image Classifier</title> | |
| <style> | |
| :root { | |
| --bg-color: #0f172a; | |
| --surface-color: #1e293b; | |
| --primary-color: #3b82f6; | |
| --primary-hover: #2563eb; | |
| --accent-color: #10b981; | |
| --text-main: #f8fafc; | |
| --text-secondary: #94a3b8; | |
| --border-color: #334155; | |
| --radius-md: 12px; | |
| --radius-lg: 16px; | |
| --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); | |
| } | |
| * { | |
| box-sizing: border-box; | |
| margin: 0; | |
| padding: 0; | |
| font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; | |
| } | |
| body { | |
| background-color: var(--bg-color); | |
| color: var(--text-main); | |
| min-height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| /* Header */ | |
| header { | |
| background-color: rgba(30, 41, 59, 0.8); | |
| backdrop-filter: blur(10px); | |
| border-bottom: 1px solid var(--border-color); | |
| padding: 1rem 2rem; | |
| position: sticky; | |
| top: 0; | |
| z-index: 100; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .brand { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| font-weight: 700; | |
| font-size: 1.25rem; | |
| color: var(--text-main); | |
| } | |
| .brand-icon { | |
| width: 32px; | |
| height: 32px; | |
| background: linear-gradient(135deg, var(--primary-color), var(--accent-color)); | |
| border-radius: 8px; | |
| display: grid; | |
| place-items: center; | |
| font-size: 1.2rem; | |
| } | |
| .anycoder-link { | |
| font-size: 0.875rem; | |
| color: var(--text-secondary); | |
| text-decoration: none; | |
| transition: color 0.2s; | |
| border: 1px solid var(--border-color); | |
| padding: 6px 12px; | |
| border-radius: 20px; | |
| } | |
| .anycoder-link:hover { | |
| color: var(--primary-color); | |
| border-color: var(--primary-color); | |
| } | |
| /* Main Layout */ | |
| main { | |
| flex: 1; | |
| display: grid; | |
| grid-template-columns: 350px 1fr; | |
| gap: 2rem; | |
| padding: 2rem; | |
| max-width: 1600px; | |
| margin: 0 auto; | |
| width: 100%; | |
| } | |
| @media (max-width: 900px) { | |
| main { | |
| grid-template-columns: 1fr; | |
| } | |
| } | |
| /* Sidebar / Controls */ | |
| .sidebar { | |
| background-color: var(--surface-color); | |
| padding: 1.5rem; | |
| border-radius: var(--radius-lg); | |
| border: 1px solid var(--border-color); | |
| height: fit-content; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 1.5rem; | |
| } | |
| h2 { | |
| font-size: 1.1rem; | |
| margin-bottom: 0.5rem; | |
| color: var(--text-main); | |
| } | |
| .control-group { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0.5rem; | |
| } | |
| label { | |
| font-size: 0.875rem; | |
| color: var(--text-secondary); | |
| font-weight: 500; | |
| } | |
| textarea { | |
| background-color: var(--bg-color); | |
| border: 1px solid var(--border-color); | |
| border-radius: var(--radius-md); | |
| color: var(--text-main); | |
| padding: 0.75rem; | |
| font-size: 0.95rem; | |
| resize: vertical; | |
| min-height: 80px; | |
| outline: none; | |
| transition: border-color 0.2s; | |
| } | |
| textarea:focus { | |
| border-color: var(--primary-color); | |
| } | |
| /* File Input Styling */ | |
| .file-drop-zone { | |
| border: 2px dashed var(--border-color); | |
| border-radius: var(--radius-md); | |
| padding: 2rem; | |
| text-align: center; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| position: relative; | |
| background-color: rgba(255, 255, 255, 0.02); | |
| } | |
| .file-drop-zone:hover, | |
| .file-drop-zone.dragover { | |
| border-color: var(--primary-color); | |
| background-color: rgba(59, 130, 246, 0.05); | |
| } | |
| .file-drop-zone input { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| opacity: 0; | |
| cursor: pointer; | |
| } | |
| .file-info { | |
| font-size: 0.875rem; | |
| color: var(--text-secondary); | |
| pointer-events: none; | |
| } | |
| .file-count { | |
| color: var(--primary-color); | |
| font-weight: bold; | |
| display: block; | |
| margin-top: 5px; | |
| } | |
| /* Buttons */ | |
| .btn { | |
| background-color: var(--primary-color); | |
| color: white; | |
| border: none; | |
| padding: 0.875rem; | |
| border-radius: var(--radius-md); | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: background-color 0.2s, transform 0.1s; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .btn:hover { | |
| background-color: var(--primary-hover); | |
| } | |
| .btn:active { | |
| transform: scale(0.98); | |
| } | |
| .btn:disabled { | |
| background-color: var(--border-color); | |
| cursor: not-allowed; | |
| opacity: 0.7; | |
| } | |
| /* Model Status */ | |
| .status-badge { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| padding: 6px 12px; | |
| border-radius: 20px; | |
| font-size: 0.75rem; | |
| font-weight: 600; | |
| background-color: rgba(255, 255, 255, 0.1); | |
| } | |
| .status-badge.loading { | |
| color: #fbbf24; | |
| background-color: rgba(251, 191, 36, 0.1); | |
| } | |
| .status-badge.ready { | |
| color: var(--accent-color); | |
| background-color: rgba(16, 185, 129, 0.1); | |
| } | |
| .status-dot { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| background-color: currentColor; | |
| } | |
| .status-badge.loading .status-dot { | |
| animation: pulse 1.5s infinite; | |
| } | |
| @keyframes pulse { | |
| 0% { | |
| opacity: 0.4; | |
| } | |
| 50% { | |
| opacity: 1; | |
| } | |
| 100% { | |
| opacity: 0.4; | |
| } | |
| } | |
| /* Results Area */ | |
| .results-area { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 1rem; | |
| } | |
| .results-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .empty-state { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| height: 100%; | |
| color: var(--text-secondary); | |
| text-align: center; | |
| padding: 4rem; | |
| background-color: var(--surface-color); | |
| border-radius: var(--radius-lg); | |
| border: 1px dashed var(--border-color); | |
| } | |
| /* Grid */ | |
| .results-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); | |
| gap: 1.5rem; | |
| } | |
| /* Image Card */ | |
| .image-card { | |
| background-color: var(--surface-color); | |
| border-radius: var(--radius-md); | |
| overflow: hidden; | |
| border: 1px solid var(--border-color); | |
| transition: transform 0.2s, box-shadow 0.2s; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .image-card:hover { | |
| transform: translateY(-2px); | |
| box-shadow: var(--shadow); | |
| border-color: var(--primary-color); | |
| } | |
| .card-img-wrapper { | |
| width: 100%; | |
| height: 200px; | |
| background-color: #000; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .card-img { | |
| width: 100%; | |
| height: 100%; | |
| object-fit: cover; | |
| transition: transform 0.3s; | |
| } | |
| .image-card:hover .card-img { | |
| transform: scale(1.05); | |
| } | |
| .card-content { | |
| padding: 1rem; | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: space-between; | |
| } | |
| .score-row { | |
| margin-top: 0.5rem; | |
| } | |
| .score-label { | |
| display: flex; | |
| justify-content: space-between; | |
| font-size: 0.8rem; | |
| color: var(--text-secondary); | |
| margin-bottom: 4px; | |
| } | |
| .score-bar-bg { | |
| height: 6px; | |
| background-color: var(--bg-color); | |
| border-radius: 3px; | |
| overflow: hidden; | |
| } | |
| .score-bar-fill { | |
| height: 100%; | |
| background: linear-gradient(90deg, var(--primary-color), var(--accent-color)); | |
| width: 0%; | |
| transition: width 1s cubic-bezier(0.4, 0, 0.2, 1); | |
| } | |
| /* Toast */ | |
| .toast-container { | |
| position: fixed; | |
| bottom: 2rem; | |
| right: 2rem; | |
| z-index: 1000; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0.5rem; | |
| } | |
| .toast { | |
| background-color: var(--surface-color); | |
| border-left: 4px solid var(--primary-color); | |
| padding: 1rem 1.5rem; | |
| border-radius: 8px; | |
| box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); | |
| color: var(--text-main); | |
| font-size: 0.9rem; | |
| animation: slideIn 0.3s ease-out; | |
| max-width: 300px; | |
| } | |
| @keyframes slideIn { | |
| from { | |
| transform: translateX(100%); | |
| opacity: 0; | |
| } | |
| to { | |
| transform: translateX(0); | |
| opacity: 1; | |
| } | |
| } | |
| /* Loader */ | |
| .spinner { | |
| width: 18px; | |
| height: 18px; | |
| border: 2px solid rgba(255, 255, 255, 0.3); | |
| border-top-color: #fff; | |
| border-radius: 50%; | |
| animation: spin 0.8s linear infinite; | |
| } | |
| @keyframes spin { | |
| to { | |
| transform: rotate(360deg); | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <div class="brand"> | |
| <div class="brand-icon">🔮</div> | |
| <span>NeuroSort CLIP</span> | |
| </div> | |
| <a href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank" class="anycoder-link"> | |
| Built with anycoder | |
| </a> | |
| </header> | |
| <main> | |
| <!-- Sidebar Controls --> | |
| <aside class="sidebar"> | |
| <!-- Model Status --> | |
| <div class="control-group"> | |
| <label>System Status</label> | |
| <div id="modelStatus" class="status-badge loading"> | |
| <div class="status-dot"></div> | |
| <span id="statusText">Initializing Model...</span> | |
| </div> | |
| </div> | |
| <hr style="border: 0; border-top: 1px solid var(--border-color);"> | |
| <!-- Text Input --> | |
| <div class="control-group"> | |
| <label for="textInput">Natural Language Query</label> | |
| <textarea id="textInput" placeholder="Describe what you want to find (e.g., 'a futuristic city at night', 'a golden retriever', 'a red sports car')..."></textarea> | |
| </div> | |
| <!-- Image Upload --> | |
| <div class="control-group"> | |
| <label>Batch Images</label> | |
| <div class="file-drop-zone" id="dropZone"> | |
| <input type="file" id="fileInput" multiple accept="image/png, image/jpeg, image/webp, image/gif"> | |
| <div class="file-info"> | |
| <span>Click or Drag & Drop images here</span> | |
| <span class="file-count" id="fileCount">0 files selected</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Action Button --> | |
| <button id="classifyBtn" class="btn" disabled> | |
| <span>Classify Images</span> | |
| </button> | |
| </aside> | |
| <!-- Results Section --> | |
| <section class="results-area"> | |
| <div class="results-header"> | |
| <h2>Classification Results</h2> | |
| <span style="font-size: 0.875rem; color: var(--text-secondary);" id="resultCount"></span> | |
| </div> | |
| <div id="resultsContainer"> | |
| <div class="empty-state"> | |
| <div style="font-size: 3rem; margin-bottom: 1rem; opacity: 0.5;">🖼️</div> | |
| <h3>No results yet</h3> | |
| <p>Upload images and enter a prompt to start classifying.</p> | |
| </div> | |
| </div> | |
| </section> | |
| </main> | |
| <div class="toast-container" id="toastContainer"></div> | |
| <!-- Import Transformers.js --> | |
| <script type="module"> | |
| import { pipeline, env } from 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.14.0'; | |
| // Configuration | |
| env.allowLocalModels = false; | |
| env.useBrowserCache = true; | |
| // DOM Elements | |
| const modelStatus = document.getElementById('modelStatus'); | |
| const statusText = document.getElementById('statusText'); | |
| const textInput = document.getElementById('textInput'); | |
| const fileInput = document.getElementById('fileInput'); | |
| const dropZone = document.getElementById('dropZone'); | |
| const fileCount = document.getElementById('fileCount'); | |
| const classifyBtn = document.getElementById('classifyBtn'); | |
| const resultsContainer = document.getElementById('resultsContainer'); | |
| const resultCount = document.getElementById('resultCount'); | |
| const toastContainer = document.getElementById('toastContainer'); | |
| // State | |
| let classifier = null; | |
| let selectedFiles = []; | |
| // --- 1. Model Loading --- | |
| async function loadModel() { | |
| try { | |
| statusText.textContent = "Downloading CLIP Model (60MB)..."; | |
| // Using Xenova's CLIP model optimized for browser | |
| classifier = await pipeline('zero-shot-image-classification', 'Xenova/clip-vit-base-patch32', { | |
| progress_callback: (data) => { | |
| if (data.status === 'progress') { | |
| const percent = Math.round(data.progress || 0); | |
| statusText.textContent = `Loading Model... ${percent}%`; | |
| } | |
| } | |
| }); | |
| // Update UI to ready | |
| modelStatus.classList.remove('loading'); | |
| modelStatus.classList.add('ready'); | |
| statusText.textContent = "Model Ready"; | |
| showToast("Model loaded successfully!", "success"); | |
| checkReady(); | |
| } catch (error) { | |
| console.error(error); | |
| statusText.textContent = "Error Loading Model"; | |
| showToast("Failed to load AI model.", "error"); | |
| } | |
| } | |
| // --- 2. File Handling --- | |
| function handleFiles(files) { | |
| selectedFiles = Array.from(files).filter(f => f.type.startsWith('image/')); | |
| if (selectedFiles.length > 0) { | |
| fileCount.textContent = `${selectedFiles.length} files ready`; | |
| dropZone.style.borderColor = 'var(--primary-color)'; | |
| } else { | |
| fileCount.textContent = "0 files selected"; | |
| dropZone.style.borderColor = 'var(--border-color)'; | |
| } | |
| checkReady(); | |
| } | |
| fileInput.addEventListener('change', (e) => handleFiles(e.target.files)); | |
| // Drag and Drop | |
| 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'); | |
| handleFiles(e.dataTransfer.files); | |
| }); | |
| // --- 3. Interaction Logic --- | |
| function checkReady() { | |
| const text = textInput.value.trim(); | |
| const hasFiles = selectedFiles.length > 0; | |
| const isModelReady = classifier !== null; | |
| classifyBtn.disabled = !(hasFiles && text && isModelReady); | |
| if (isModelReady) { | |
| if(!hasFiles) classifyBtn.title = "Please upload images"; | |
| else if(!text) classifyBtn.title = "Please enter a text prompt"; | |
| else classifyBtn.title = "Classify images"; | |
| } else { | |
| classifyBtn.title = "Model is still loading..."; | |
| } | |
| } | |
| textInput.addEventListener('input', checkReady); | |
| // --- 4. Classification Logic (Fixed) --- | |
| classifyBtn.addEventListener('click', async () => { | |
| const prompt = textInput.value.trim(); | |
| if (!classifier || !prompt || selectedFiles.length === 0) return; | |
| // UI Loading State | |
| const originalBtnText = classifyBtn.innerHTML; | |
| classifyBtn.innerHTML = `<div class="spinner"></div> Processing...`; | |
| classifyBtn.disabled = true; | |
| // Prepare results container | |
| resultsContainer.innerHTML = ''; | |
| const grid = document.createElement('div'); | |
| grid.className = 'results-grid'; | |
| resultsContainer.appendChild(grid); | |
| const startTime = performance.now(); | |
| const results = []; | |
| try { | |
| // FIX: Process images one by one to avoid "unsupported input type: object" | |
| // Passing an array of File objects directly can sometimes cause issues depending on | |
| // the specific version of transformers.js pre-processor. Looping is safer and | |
| // allows for better progress feedback. | |
| for (let i = 0; i < selectedFiles.length; i++) { | |
| const file = selectedFiles[i]; | |
| // Update status text to show progress | |
| resultsContainer.innerHTML = `<div style="text-align:center; padding: 2rem; color: var(--text-secondary);">Analyzing image ${i + 1} of ${selectedFiles.length}...<br><small>This runs locally on your CPU.</small></div>`; | |
| // Run inference for a single image | |
| // The pipeline expects (image, labels) | |
| const prediction = await classifier(file, [prompt]); | |
| // prediction is usually an array of matches: [{ label: "prompt", score: 0.99 }] | |
| // We take the first (best) match | |
| results.push(prediction[0]); | |
| } | |
| const endTime = performance.now(); | |
| const duration = ((endTime - startTime) / 1000).toFixed(2); | |
| renderResults(results, grid); | |
| showToast(`Classified ${selectedFiles.length} images in ${duration}s`, "success"); | |
| } catch (error) { | |
| console.error(error); | |
| showToast("An error occurred during classification.", "error"); | |
| resultsContainer.innerHTML = `<div class="empty-state"><p style="color: #ef4444">Error: ${error.message}</p></div>`; | |
| } finally { | |
| // Reset Button | |
| classifyBtn.innerHTML = originalBtnText; | |
| checkReady(); | |
| } | |
| }); | |
| // --- 5. Rendering --- | |
| function renderResults(data, gridElement) { | |
| // Clear the loading text | |
| gridElement.innerHTML = ''; | |
| // Create cards | |
| data.forEach((result, index) => { | |
| const score = result.score; // 0 to 1 | |
| const percentage = (score * 100).toFixed(1); | |
| const fileUrl = URL.createObjectURL(selectedFiles[index]); | |
| const card = document.createElement('div'); | |
| card.className = 'image-card'; | |
| // Color coding based on score | |
| let barColor = 'var(--primary-color)'; | |
| if(score > 0.8) barColor = 'var(--accent-color)'; | |
| else if(score < 0.3) barColor = '#ef4444'; // red | |
| card.innerHTML = ` | |
| <div class="card-img-wrapper"> | |
| <img src="${fileUrl}" class="card-img" alt="Classified Image" loading="lazy"> | |
| </div> | |
| <div class="card-content"> | |
| <div class="score-row"> | |
| <div class="score-label"> | |
| <span>Match Probability</span> | |
| <span style="font-weight:bold; color:${barColor}">${percentage}%</span> | |
| </div> | |
| <div class="score-bar-bg"> | |
| <div class="score-bar-fill" style="width: 0%; background: ${barColor}"></div> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| gridElement.appendChild(card); | |
| // Trigger animation after append | |
| setTimeout(() => { | |
| card.querySelector('.score-bar-fill').style.width = `${percentage}%`; | |
| }, 50 + (index * 50)); | |
| }); | |
| resultCount.textContent = `${data.length} images processed`; | |
| } | |
| // --- Utilities --- | |
| function showToast(message, type = 'info') { | |
| const toast = document.createElement('div'); | |
| toast.className = 'toast'; | |
| toast.textContent = message; | |
| if (type === 'error') toast.style.borderLeftColor = '#ef4444'; | |
| if (type === 'success') toast.style.borderLeftColor = 'var(--accent-color)'; | |
| toastContainer.appendChild(toast); | |
| setTimeout(() => { | |
| toast.style.opacity = '0'; | |
| toast.style.transform = 'translateX(100%)'; | |
| setTimeout(() => toast.remove(), 300); | |
| }, 3000); | |
| } | |
| // Initialize | |
| loadModel(); | |
| </script> | |
| </body> | |
| </html> |