Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Fungus Among Us Classifier</title> | |
| <link rel="icon" type="image/x-icon" href="/static/favicon.ico"> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://unpkg.com/feather-icons"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script> | |
| <style> | |
| .dropzone { | |
| border: 2px dashed #9CA3AF; | |
| transition: all 0.3s ease; | |
| } | |
| .dropzone-active { | |
| border-color: #10B981; | |
| background-color: rgba(16, 185, 129, 0.05); | |
| } | |
| .dropzone-reject { | |
| border-color: #EF4444; | |
| background-color: rgba(239, 68, 68, 0.05); | |
| } | |
| .progress-bar { | |
| transition: width 0.3s ease; | |
| } | |
| .result-card { | |
| transition: all 0.3s ease; | |
| } | |
| .result-card:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-50 min-h-screen"> | |
| <div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> | |
| <!-- Header --> | |
| <header class="text-center mb-8"> | |
| <div class="flex justify-between items-center mb-4"> | |
| <div class="flex items-center"> | |
| <i data-feather="moon" class="w-8 h-8 text-emerald-600 mr-2"></i> | |
| <h1 id="titleText" class="text-2xl font-bold text-gray-800">Fungus Among Us</h1> | |
| </div> | |
| <div class="relative"> | |
| <select id="languageSelect" class="appearance-none bg-white border border-gray-300 rounded-md px-3 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-emerald-500"> | |
| <option value="en">English</option> | |
| <option value="es">Español</option> | |
| <option value="fr">Français</option> | |
| <option value="de">Deutsch</option> | |
| <option value="pl">Polski</option> | |
| </select> | |
| <i data-feather="chevron-down" class="absolute right-2 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-500 pointer-events-none"></i> | |
| </div> | |
| </div> | |
| <p id="subtitleText" class="text-gray-600 max-w-2xl mx-auto"> | |
| Upload mushroom images to identify species with our AI classifier. | |
| Supports common edible and poisonous varieties. | |
| </p> | |
| </header> | |
| <!-- Main Content --> | |
| <main> | |
| <!-- Upload Section --> | |
| <section class="mb-12"> | |
| <div id="dropzone" class="dropzone rounded-xl p-8 md:p-12 text-center cursor-pointer bg-white shadow-sm"> | |
| <div class="max-w-md mx-auto"> | |
| <i data-feather="upload-cloud" class="w-12 h-12 text-emerald-500 mx-auto mb-4"></i> | |
| <h2 id="uploadTitle" class="text-xl font-semibold text-gray-800 mb-2">Upload Mushroom Image</h2> | |
| <p id="uploadSubtitle" class="text-gray-500 mb-6">Drag & drop or click to browse (JPG, PNG up to 5MB)</p> | |
| <input type="file" id="fileInput" class="hidden" accept="image/jpeg,image/png" /> | |
| <button id="uploadBtn" class="px-6 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 transition"> | |
| <span id="selectFileText">Select File</span> | |
| </button> | |
| <p id="privacyText" class="text-xs text-gray-400 mt-4">We don't store your images after processing</p> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- Status Section --> | |
| <section id="statusSection" class="hidden mb-12"> | |
| <div class="bg-white rounded-xl p-6 shadow-sm"> | |
| <h2 id="processingText" class="text-xl font-semibold text-gray-800 mb-4">Processing</h2> | |
| <div class="flex items-center mb-4"> | |
| <div class="flex-1 bg-gray-200 rounded-full h-2.5"> | |
| <div id="progressBar" class="progress-bar bg-emerald-500 h-2.5 rounded-full" style="width: 0%"></div> | |
| </div> | |
| <span id="progressText" class="ml-4 text-sm font-medium text-gray-700">0%</span> | |
| </div> | |
| <div class="flex items-center"> | |
| <div class="animate-spin rounded-full h-5 w-5 border-b-2 border-emerald-500 mr-3"></div> | |
| <p id="statusMessage" class="text-gray-600"><span id="uploadingText">Uploading image</span>...</p> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- Results Section --> | |
| <section id="resultsSection" class="hidden"> | |
| <h2 id="resultsTitle" class="text-2xl font-semibold text-gray-800 mb-6">Classification Results</h2> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-6"> | |
| <!-- Image Preview --> | |
| <div class="result-card bg-white rounded-xl p-4 shadow-sm"> | |
| <h3 id="yourImageText" class="text-lg font-medium text-gray-800 mb-3">Your Image</h3> | |
| <div class="rounded-lg overflow-hidden bg-gray-100"> | |
| <img id="previewImage" src="" alt="Uploaded mushroom" class="w-full h-64 object-contain" /> | |
| </div> | |
| </div> | |
| <!-- Classification Results --> | |
| <div class="result-card bg-white rounded-xl p-4 shadow-sm"> | |
| <h3 id="predictionText" class="text-lg font-medium text-gray-800 mb-3">Prediction</h3> | |
| <div id="resultsContainer" class="space-y-4"> | |
| <!-- Results will be populated here --> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="mt-6 text-center"> | |
| <button id="newUploadBtn" class="px-6 py-2 border border-emerald-500 text-emerald-500 rounded-lg hover:bg-emerald-50 transition"> | |
| <i data-feather="plus" class="w-4 h-4 inline mr-2"></i> <span id="uploadAnotherText">Upload Another</span> | |
| </button> | |
| </div> | |
| </section> | |
| <!-- Error Section --> | |
| <section id="errorSection" class="hidden"> | |
| <div class="bg-red-50 border-l-4 border-red-500 p-4 rounded-lg"> | |
| <div class="flex"> | |
| <div class="flex-shrink-0"> | |
| <i data-feather="alert-triangle" class="h-5 w-5 text-red-500"></i> | |
| </div> | |
| <div class="ml-3"> | |
| <h3 class="text-sm font-medium text-red-800" id="errorTitle">Error</h3> | |
| <div class="mt-2 text-sm text-red-700" id="errorMessage"> | |
| <!-- Error message will be inserted here --> | |
| </div> | |
| <div class="mt-4"> | |
| <button id="tryAgainBtn" class="px-4 py-1 text-sm font-medium text-red-800 hover:text-red-700"> | |
| Try Again | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| </main> | |
| <!-- Footer --> | |
| <footer class="mt-16 text-center text-gray-500 text-sm"> | |
| <p id="copyrightText">© 2023 Fungus Among Us Classifier. Not responsible for foraging decisions.</p> | |
| <p id="disclaimerText" class="mt-1">Always consult an expert before consuming wild mushrooms.</p> | |
| </footer> | |
| </div> | |
| <script> | |
| // Initialize | |
| feather.replace(); | |
| // Translations | |
| const translations = { | |
| en: { | |
| title: "Fungus Among Us", | |
| subtitle: "Upload mushroom images to identify species with our AI classifier. Supports common edible and poisonous varieties.", | |
| uploadTitle: "Upload Mushroom Image", | |
| uploadSubtitle: "Drag & drop or click to browse (JPG, PNG up to 5MB)", | |
| selectFile: "Select File", | |
| privacy: "We don't store your images after processing", | |
| processing: "Processing", | |
| uploading: "Uploading image", | |
| resultsTitle: "Classification Results", | |
| yourImage: "Your Image", | |
| prediction: "Prediction", | |
| uploadAnother: "Upload Another", | |
| copyright: "© 2023 Fungus Among Us Classifier. Not responsible for foraging decisions.", | |
| disclaimer: "Always consult an expert before consuming wild mushrooms." | |
| }, | |
| es: { | |
| title: "Hongo Entre Nosotros", | |
| subtitle: "Sube imágenes de hongos para identificar especies con nuestro clasificador de IA. Soporta variedades comestibles y venenosas comunes.", | |
| uploadTitle: "Subir Imagen de Hongo", | |
| uploadSubtitle: "Arrastra y suelta o haz clic para buscar (JPG, PNG hasta 5MB)", | |
| selectFile: "Seleccionar Archivo", | |
| privacy: "No almacenamos tus imágenes después del procesamiento", | |
| processing: "Procesando", | |
| uploading: "Subiendo imagen", | |
| resultsTitle: "Resultados de Clasificación", | |
| yourImage: "Tu Imagen", | |
| prediction: "Predicción", | |
| uploadAnother: "Subir Otro", | |
| copyright: "© 2023 Clasificador de Hongos Entre Nosotros. No responsable de decisiones de recolección.", | |
| disclaimer: "Siempre consulta a un experto antes de consumir hongos silvestres." | |
| }, | |
| fr: { | |
| title: "Champignon Parmi Nous", | |
| subtitle: "Téléchargez des images de champignons pour identifier les espèces avec notre classificateur IA. Prend en charge les variétés comestibles et vénéneuses courantes.", | |
| uploadTitle: "Télécharger une Image de Champignon", | |
| uploadSubtitle: "Glisser-déposer ou cliquer pour parcourir (JPG, PNG jusqu'à 5MB)", | |
| selectFile: "Sélectionner un Fichier", | |
| privacy: "Nous ne stockons pas vos images après traitement", | |
| processing: "Traitement en cours", | |
| uploading: "Téléchargement de l'image", | |
| resultsTitle: "Résultats de Classification", | |
| yourImage: "Votre Image", | |
| prediction: "Prédiction", | |
| uploadAnother: "Télécharger un Autre", | |
| copyright: "© 2023 Classificateur de Champignons Parmi Nous. Non responsable des décisions de cueillette.", | |
| disclaimer: "Consultez toujours un expert avant de consommer des champignons sauvages." | |
| }, | |
| de: { | |
| title: "Pilz Unter Uns", | |
| subtitle: "Laden Sie Pilzbilder hoch, um Arten mit unserem KI-Klassifikator zu identifizieren. Unterstützt häufige essbare und giftige Sorten.", | |
| uploadTitle: "Pilzbild Hochladen", | |
| uploadSubtitle: "Ziehen und ablegen oder klicken, um zu durchsuchen (JPG, PNG bis zu 5MB)", | |
| selectFile: "Datei Auswählen", | |
| privacy: "Wir speichern Ihre Bilder nicht nach der Verarbeitung", | |
| processing: "Wird Verarbeitet", | |
| uploading: "Bild wird Hochgeladen", | |
| resultsTitle: "Klassifikationsergebnisse", | |
| yourImage: "Ihr Bild", | |
| prediction: "Vorhersage", | |
| uploadAnother: "Weiteres Hochladen", | |
| copyright: "© 2023 Pilz Unter Uns Klassifikator. Nicht verantwortlich für Sammelentscheidungen.", | |
| disclaimer: "Konsultieren Sie immer einen Experten, bevor Sie Wildpilze verzehren." | |
| }, | |
| pl: { | |
| title: "Grzyb Wśród Nas", | |
| subtitle: "Prześlij zdjęcia grzybów, aby zidentyfikować gatunki za pomocą naszego klasyfikatora AI. Obsługuje popularne jadalne i trujące odmiany.", | |
| uploadTitle: "Prześlij Zdjęcie Grzyba", | |
| uploadSubtitle: "Przeciągnij i upuść lub kliknij, aby przeglądać (JPG, PNG do 5MB)", | |
| selectFile: "Wybierz Plik", | |
| privacy: "Nie przechowujemy Twoich zdjęć po przetworzeniu", | |
| processing: "Przetwarzanie", | |
| uploading: "Przesyłanie obrazu", | |
| resultsTitle: "Wyniki Klasyfikacji", | |
| yourImage: "Twoje Zdjęcie", | |
| prediction: "Przewidywanie", | |
| uploadAnother: "Prześlij Kolejne", | |
| copyright: "© 2023 Klasyfikator Grzyb Wśród Nas. Nie ponosimy odpowiedzialności za decyzje dotyczące zbieractwa.", | |
| disclaimer: "Zawsze skonsultuj się z ekspertem przed spożyciem dzikich grzybów." | |
| } | |
| }; | |
| // Language selector | |
| document.getElementById('languageSelect').addEventListener('change', function() { | |
| const lang = this.value; | |
| updateLanguage(lang); | |
| }); | |
| function updateLanguage(lang) { | |
| const t = translations[lang] || translations.en; | |
| // Update all text elements | |
| document.getElementById('titleText').textContent = t.title; | |
| document.getElementById('subtitleText').textContent = t.subtitle; | |
| document.getElementById('uploadTitle').textContent = t.uploadTitle; | |
| document.getElementById('uploadSubtitle').textContent = t.uploadSubtitle; | |
| document.getElementById('selectFileText').textContent = t.selectFile; | |
| document.getElementById('privacyText').textContent = t.privacy; | |
| document.getElementById('processingText').textContent = t.processing; | |
| document.getElementById('uploadingText').textContent = t.uploading; | |
| document.getElementById('resultsTitle').textContent = t.resultsTitle; | |
| document.getElementById('yourImageText').textContent = t.yourImage; | |
| document.getElementById('predictionText').textContent = t.prediction; | |
| document.getElementById('uploadAnotherText').textContent = t.uploadAnother; | |
| document.getElementById('copyrightText').textContent = t.copyright; | |
| document.getElementById('disclaimerText').textContent = t.disclaimer; | |
| // Update selected option in dropdown | |
| const select = document.getElementById('languageSelect'); | |
| for (let i = 0; i < select.options.length; i++) { | |
| if (select.options[i].value === lang) { | |
| select.selectedIndex = i; | |
| break; | |
| } | |
| } | |
| // Update feather icons (they might contain text) | |
| feather.replace(); | |
| } | |
| // Initialize with default language | |
| updateLanguage('en'); | |
| // DOM Elements | |
| const dropzone = document.getElementById('dropzone'); | |
| const fileInput = document.getElementById('fileInput'); | |
| const uploadBtn = document.getElementById('uploadBtn'); | |
| const statusSection = document.getElementById('statusSection'); | |
| const resultsSection = document.getElementById('resultsSection'); | |
| const errorSection = document.getElementById('errorSection'); | |
| const progressBar = document.getElementById('progressBar'); | |
| const progressText = document.getElementById('progressText'); | |
| const statusMessage = document.getElementById('statusMessage'); | |
| const previewImage = document.getElementById('previewImage'); | |
| const resultsContainer = document.getElementById('resultsContainer'); | |
| const newUploadBtn = document.getElementById('newUploadBtn'); | |
| const tryAgainBtn = document.getElementById('tryAgainBtn'); | |
| const errorMessage = document.getElementById('errorMessage'); | |
| const errorTitle = document.getElementById('errorTitle'); | |
| // State | |
| let currentTaskId = null; | |
| let statusCheckInterval = null; | |
| // Event Listeners | |
| uploadBtn.addEventListener('click', () => fileInput.click()); | |
| fileInput.addEventListener('change', handleFileSelect); | |
| newUploadBtn.addEventListener('click', resetUpload); | |
| tryAgainBtn.addEventListener('click', resetUpload); | |
| // Drag and Drop Events | |
| ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { | |
| dropzone.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('dropzone-active'); | |
| } | |
| function unhighlight() { | |
| dropzone.classList.remove('dropzone-active'); | |
| } | |
| dropzone.addEventListener('drop', handleDrop, false); | |
| function handleDrop(e) { | |
| const dt = e.dataTransfer; | |
| const files = dt.files; | |
| if (files.length) { | |
| fileInput.files = files; | |
| handleFileSelect({ target: fileInput }); | |
| } | |
| } | |
| // File Handling | |
| function handleFileSelect(e) { | |
| const file = e.target.files[0]; | |
| if (!file) return; | |
| // Validate file type | |
| if (!file.type.match('image.*')) { | |
| showError('Invalid File Type', 'Please upload an image file (JPEG or PNG)'); | |
| return; | |
| } | |
| // Validate file size (5MB max) | |
| if (file.size > 5 * 1024 * 1024) { | |
| showError('File Too Large', 'Maximum file size is 5MB'); | |
| return; | |
| } | |
| // Show preview | |
| const reader = new FileReader(); | |
| reader.onload = function(e) { | |
| previewImage.src = e.target.result; | |
| }; | |
| reader.readAsDataURL(file); | |
| // Start upload process | |
| startUpload(file); | |
| } | |
| // Upload Process | |
| async function startUpload(file) { | |
| try { | |
| // Show status section | |
| statusSection.classList.remove('hidden'); | |
| dropzone.classList.add('hidden'); | |
| updateStatus('Uploading image...', 0); | |
| const formData = new FormData(); | |
| formData.append('file', file); | |
| const response = await fetch('/upload', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| if (!response.ok) { | |
| throw new Error('Upload failed'); | |
| } | |
| const data = await response.json(); | |
| currentTaskId = data.task_id; | |
| // Start checking status | |
| checkStatus(); | |
| } catch (error) { | |
| showError('Upload Failed', error.message); | |
| } | |
| } | |
| // Status Checking | |
| async function checkStatus() { | |
| updateStatus('Analyzing mushroom...', 30); | |
| statusCheckInterval = setInterval(async () => { | |
| try { | |
| const response = await fetch(`/status/${currentTaskId}`); | |
| const data = await response.json(); | |
| if (data.status === 'completed') { | |
| clearInterval(statusCheckInterval); | |
| updateStatus('Finalizing results...', 100); | |
| await new Promise(resolve => setTimeout(resolve, 500)); | |
| showResults(data); | |
| } else if (data.status === 'processing') { | |
| // Update progress - simple linear progression for demo | |
| const currentProgress = parseInt(progressBar.style.width) || 30; | |
| const newProgress = Math.min(currentProgress + 10, 90); | |
| updateStatus(data.message || 'Analyzing mushroom...', newProgress); | |
| } | |
| } catch (error) { | |
| clearInterval(statusCheckInterval); | |
| showError('Processing Error', error.message); | |
| } | |
| }, 2000); | |
| } | |
| // Update Status UI | |
| function updateStatus(message, percent) { | |
| statusMessage.textContent = message; | |
| progressBar.style.width = `${percent}%`; | |
| progressText.textContent = `${percent}%`; | |
| } | |
| // Show Results | |
| async function showResults(data) { | |
| try { | |
| const predictions = data.predictions || []; | |
| const topPrediction = data.top_prediction || predictions[0]; | |
| // Populate results | |
| resultsContainer.innerHTML = ''; | |
| // Add Wikipedia image and description for top prediction | |
| if (topPrediction) { | |
| const wikiCard = document.createElement('div'); | |
| wikiCard.className = 'mb-6 p-4 bg-white rounded-xl shadow-sm border border-gray-100'; | |
| wikiCard.innerHTML = ` | |
| <h3 class="text-lg font-medium text-gray-800 mb-3">${topPrediction.class}</h3> | |
| <div class="flex flex-col md:flex-row gap-4"> | |
| <div class="w-full md:w-1/3"> | |
| <img src="https://en.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(topPrediction.class)}&prop=pageimages&format=json&pithumbsize=300" | |
| alt="${topPrediction.class}" | |
| class="w-full h-48 object-cover rounded-lg" | |
| onerror="this.src='http://static.photos/nature/320x240'"> | |
| </div> | |
| <div class="w-full md:w-2/3"> | |
| <p class="text-gray-700">Loading description from Wikipedia...</p> | |
| </div> | |
| </div> | |
| `; | |
| resultsContainer.appendChild(wikiCard); | |
| // Fetch Wikipedia description | |
| fetchWikipediaDescription(topPrediction.class); | |
| } | |
| // Add all predictions | |
| predictions.forEach(result => { | |
| const resultItem = document.createElement('div'); | |
| resultItem.className = 'p-3 rounded-lg border'; | |
| resultItem.style.borderColor = result.edible ? '#D1FAE5' : '#FEE2E2'; | |
| resultItem.style.backgroundColor = result.edible ? '#ECFDF5' : '#FEF2F2'; | |
| const confidencePercentage = result.probability.toFixed(2); | |
| const isEdible = !result.class.toLowerCase().includes('amanita'); // Simple edible check for demo | |
| resultItem.innerHTML = ` | |
| <div class="flex justify-between items-start mb-1"> | |
| <h4 class="font-medium ${isEdible ? 'text-emerald-800' : 'text-red-800'}"> | |
| ${result.class} | |
| </h4> | |
| <span class="text-xs px-2 py-1 rounded-full ${result.edible ? 'bg-emerald-100 text-emerald-800' : 'bg-red-100 text-red-800'}"> | |
| ${confidencePercentage}% confidence | |
| </span> | |
| </div> | |
| <div class="w-full bg-gray-200 rounded-full h-1.5 mb-2"> | |
| <div class="h-1.5 rounded-full ${result.edible ? 'bg-emerald-500' : 'bg-red-500'}" style="width: ${confidencePercentage}%"></div> | |
| </div> | |
| <p class="text-sm ${isEdible ? 'text-emerald-700' : 'text-red-700'}"> | |
| <i data-feather="${isEdible ? 'check-circle' : 'alert-circle'}" class="w-4 h-4 inline mr-1"></i> | |
| ${isEdible ? 'Likely Edible' : 'Likely Poisonous'} | |
| </p> | |
| `; | |
| resultsContainer.appendChild(resultItem); | |
| }); | |
| // Show results section | |
| statusSection.classList.add('hidden'); | |
| resultsSection.classList.remove('hidden'); | |
| // Update feather icons in new elements | |
| feather.replace(); | |
| } catch (error) { | |
| showError('Results Error', error.message); | |
| } | |
| } | |
| // Fetch Wikipedia Description | |
| async function fetchWikipediaDescription(species) { | |
| try { | |
| // This would need to be proxied through your backend due to CORS | |
| const response = await fetch(`/wiki?species=${encodeURIComponent(species)}`); | |
| const data = await response.json(); | |
| if (data.description) { | |
| const descElement = document.querySelector(`[data-species="${species}"] .wiki-description`); | |
| if (descElement) { | |
| descElement.innerHTML = data.description; | |
| } | |
| } | |
| } catch (error) { | |
| console.error('Failed to fetch Wikipedia description:', error); | |
| } | |
| } | |
| // Error Handling | |
| function showError(title, message) { | |
| clearInterval(statusCheckInterval); | |
| errorTitle.textContent = title; | |
| errorMessage.textContent = message; | |
| statusSection.classList.add('hidden'); | |
| resultsSection.classList.add('hidden'); | |
| errorSection.classList.remove('hidden'); | |
| } | |
| // Reset Upload | |
| function resetUpload() { | |
| fileInput.value = ''; | |
| currentTaskId = null; | |
| clearInterval(statusCheckInterval); | |
| statusSection.classList.add('hidden'); | |
| resultsSection.classList.add('hidden'); | |
| errorSection.classList.add('hidden'); | |
| dropzone.classList.remove('hidden'); | |
| progressBar.style.width = '0%'; | |
| progressText.textContent = '0%'; | |
| } | |
| </script> | |
| </body> | |
| </html> | |