Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Object Tracking Stream</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); | |
| * { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } | |
| body { | |
| background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 50%, #16213e 100%); | |
| min-height: 100vh; | |
| } | |
| .glass { | |
| background: rgba(255, 255, 255, 0.05); | |
| backdrop-filter: blur(20px); | |
| -webkit-backdrop-filter: blur(20px); | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| } | |
| .glass-strong { | |
| background: rgba(255, 255, 255, 0.08); | |
| backdrop-filter: blur(30px); | |
| -webkit-backdrop-filter: blur(30px); | |
| border: 1px solid rgba(255, 255, 255, 0.15); | |
| } | |
| .glow-green { box-shadow: 0 0 20px rgba(34, 197, 94, 0.3); } | |
| .glow-red { box-shadow: 0 0 20px rgba(239, 68, 68, 0.3); } | |
| .animate-pulse-slow { animation: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite; } | |
| .card-hover { transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); } | |
| .card-hover:hover { transform: translateY(-8px) scale(1.02); } | |
| </style> | |
| </head> | |
| <body class="text-white"> | |
| <div id="app" class="min-h-screen p-4 md:p-8"> | |
| <!-- Header --> | |
| <div class="max-w-7xl mx-auto mb-8"> | |
| <h1 class="text-4xl md:text-5xl font-bold text-center mb-2 bg-gradient-to-r from-green-400 via-emerald-400 to-teal-400 bg-clip-text text-transparent"> | |
| Object Tracking | |
| </h1> | |
| <p class="text-center text-gray-400 text-sm md:text-base">Real-time wildlife detection and identification</p> | |
| </div> | |
| <!-- Main Content --> | |
| <div class="max-w-7xl mx-auto"> | |
| <!-- YouTube URL Input --> | |
| <div class="glass-strong rounded-2xl p-4 mb-6"> | |
| <div class="flex flex-col md:flex-row gap-3 items-end"> | |
| <div class="flex-1"> | |
| <label class="block text-sm font-medium text-gray-300 mb-2">YouTube Video URL</label> | |
| <input | |
| type="text" | |
| v-model="youtubeUrl" | |
| placeholder="https://youtu.be/..." | |
| class="w-full px-4 py-2.5 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-400 focus:border-transparent transition-all" | |
| @keyup.enter="changeYouTubeUrl" | |
| /> | |
| </div> | |
| <button | |
| @click="changeYouTubeUrl" | |
| :disabled="isChangingUrl || !youtubeUrl" | |
| :class="[ | |
| 'px-6 py-2.5 rounded-xl font-semibold transition-all', | |
| isChangingUrl || !youtubeUrl | |
| ? 'bg-gray-600/50 text-gray-400 cursor-not-allowed' | |
| : 'bg-green-500 hover:bg-green-600 text-white hover:shadow-lg hover:shadow-green-500/50' | |
| ]" | |
| > | |
| <span v-if="!isChangingUrl">Change Stream</span> | |
| <span v-else class="flex items-center gap-2"> | |
| <span class="animate-spin">⏳</span> | |
| <span>Changing...</span> | |
| </span> | |
| </button> | |
| </div> | |
| <div v-if="urlChangeMessage" :class="['mt-3 text-sm px-3 py-2 rounded-lg', urlChangeMessage.type === 'success' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400']"> | |
| {{ urlChangeMessage.text }} | |
| </div> | |
| </div> | |
| <!-- Stats Bar --> | |
| <div class="grid grid-cols-3 gap-4 mb-6"> | |
| <div class="glass rounded-2xl p-4 text-center"> | |
| <div class="text-3xl font-bold text-green-400" v-text="stats.fps.toFixed(1)"></div> | |
| <div class="text-xs text-gray-400 mt-1">FPS</div> | |
| </div> | |
| <div class="glass rounded-2xl p-4 text-center"> | |
| <div class="text-3xl font-bold text-blue-400" v-text="stats.detections"></div> | |
| <div class="text-xs text-gray-400 mt-1">Detections</div> | |
| </div> | |
| <div class="glass rounded-2xl p-4 text-center"> | |
| <div class="text-3xl font-bold text-purple-400" v-text="stats.frames"></div> | |
| <div class="text-xs text-gray-400 mt-1">Frames</div> | |
| </div> | |
| </div> | |
| <div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8"> | |
| <!-- Video Feed --> | |
| <div class="lg:col-span-2"> | |
| <div class="glass-strong rounded-3xl p-6"> | |
| <div class="flex items-center justify-between mb-4"> | |
| <h2 class="text-xl font-semibold">Live Video Feed</h2> | |
| <div class="flex items-center gap-2"> | |
| <div :class="['w-3 h-3 rounded-full', connectionStatus === 'connected' ? 'bg-green-400 animate-pulse-slow' : 'bg-red-400']"></div> | |
| <span class="text-sm text-gray-400" v-text="connectionStatus === 'connected' ? 'Connected' : 'Disconnected'"></span> | |
| </div> | |
| </div> | |
| <div class="rounded-2xl overflow-hidden bg-black"> | |
| <img :src="videoFrame" alt="Video stream" class="w-full h-auto" v-if="videoFrame" /> | |
| <div v-else class="w-full aspect-video flex items-center justify-center text-gray-500"> | |
| <div class="text-center"> | |
| <div class="text-4xl mb-2">📹</div> | |
| <div>Waiting for stream...</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Detection Sidebar --> | |
| <div class="lg:col-span-1"> | |
| <div class="glass-strong rounded-3xl p-6 h-full"> | |
| <h2 class="text-xl font-semibold mb-4">Active Detections</h2> | |
| <div class="space-y-3 max-h-[600px] overflow-y-auto pr-2"> | |
| <div v-if="sortedDetections.length === 0" class="text-center text-gray-500 py-8"> | |
| <div class="text-2xl mb-2">🔍</div> | |
| <div class="text-sm">No detections</div> | |
| </div> | |
| <div | |
| v-for="[trackId, entry] in sortedDetections" | |
| :key="trackId" | |
| :class="[ | |
| 'glass rounded-xl p-4 card-hover', | |
| entry.detection.wildlife?.isDangerous ? 'border-l-4 border-red-500' : 'border-l-4 border-blue-500', | |
| !entry.isLive ? 'opacity-60' : '' | |
| ]" | |
| > | |
| <div class="flex items-start justify-between mb-2"> | |
| <div class="flex-1"> | |
| <div class="flex items-center gap-2"> | |
| <span class="text-sm font-medium">ID: {{ entry.detection.track_id || 'N/A' }}</span> | |
| <span | |
| v-if="entry.isLive" | |
| class="px-2 py-0.5 text-xs font-semibold bg-green-500/20 text-green-400 rounded-full" | |
| > | |
| LIVE | |
| </span> | |
| <span | |
| v-else | |
| class="px-2 py-0.5 text-xs font-semibold bg-gray-500/20 text-gray-400 rounded-full" | |
| > | |
| {{ Math.floor((currentTime - entry.lastSeen) / 1000) }}s ago | |
| </span> | |
| </div> | |
| <div class="text-xs text-gray-400 mt-1" v-if="entry.detection.confidence"> | |
| Confidence: {{ (entry.detection.confidence * 100).toFixed(1) }}% | |
| </div> | |
| </div> | |
| </div> | |
| <div v-if="entry.detection.wildlife_status === 'pending'" class="mt-2"> | |
| <div class="text-yellow-400 text-sm flex items-center gap-2"> | |
| <span class="animate-spin">🔍</span> | |
| <span>Identifying...</span> | |
| </div> | |
| </div> | |
| <div v-else-if="entry.detection.wildlife?.isAnimal" class="mt-3"> | |
| <div class="text-lg font-semibold text-green-400" v-if="entry.detection.wildlife.commonName"> | |
| {{ entry.detection.wildlife.commonName }} | |
| </div> | |
| <div class="text-sm text-gray-400 italic" v-if="entry.detection.wildlife.scientificName"> | |
| {{ entry.detection.wildlife.scientificName }} | |
| </div> | |
| <div class="text-xs text-gray-500 mt-1" v-if="entry.detection.wildlife.conservationStatus"> | |
| {{ entry.detection.wildlife.conservationStatus }} | |
| </div> | |
| <div v-if="entry.detection.wildlife.isDangerous" class="mt-2 px-3 py-1.5 bg-red-500/20 text-red-400 rounded-lg text-xs font-semibold"> | |
| ⚠️ DANGEROUS | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- History Section --> | |
| <div class="glass-strong rounded-3xl p-6"> | |
| <h2 class="text-2xl font-semibold mb-6 flex items-center gap-2"> | |
| <span>📋</span> | |
| <span>Detected Animals</span> | |
| <span class="text-sm font-normal text-gray-400">(Past 10 Minutes)</span> | |
| </h2> | |
| <div v-if="sortedSpecies.length === 0" class="text-center text-gray-500 py-12"> | |
| <div class="text-4xl mb-3">🦋</div> | |
| <div>No animals detected yet</div> | |
| </div> | |
| <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" v-else> | |
| <div | |
| v-for="species in sortedSpecies" | |
| :key="species.key" | |
| :class="[ | |
| 'glass rounded-2xl p-6 card-hover overflow-hidden', | |
| species.wildlife.isDangerous ? 'border-2 border-red-500/50 glow-red' : 'border-2 border-green-500/30 glow-green' | |
| ]" | |
| > | |
| <!-- Thumbnail --> | |
| <div v-if="species.detection.thumbnail" class="mb-4 rounded-xl overflow-hidden"> | |
| <img | |
| :src="'data:image/jpeg;base64,' + species.detection.thumbnail" | |
| :alt="species.wildlife.commonName" | |
| class="w-full h-48 object-cover" | |
| /> | |
| </div> | |
| <!-- Header --> | |
| <div class="mb-4 pb-4 border-b border-white/10"> | |
| <div class="flex items-start justify-between"> | |
| <div class="flex-1"> | |
| <h3 class="text-xl font-bold text-green-400 mb-1"> | |
| {{ species.wildlife.commonName || 'Unknown Animal' }} | |
| </h3> | |
| <div class="text-sm text-gray-400" v-if="species.trackIds.length > 1"> | |
| {{ species.trackIds.length }} detected (IDs: {{ species.trackIds.join(', ') }}) | |
| </div> | |
| <div class="text-sm text-gray-400" v-else> | |
| ID: {{ species.trackIds[0] }} | |
| </div> | |
| </div> | |
| </div> | |
| <div class="text-sm text-gray-400 italic mt-1" v-if="species.wildlife.scientificName"> | |
| {{ species.wildlife.scientificName }} | |
| </div> | |
| </div> | |
| <!-- Content --> | |
| <div class="space-y-3 text-sm"> | |
| <div v-if="species.wildlife.description"> | |
| <div class="text-xs font-semibold text-gray-400 uppercase mb-1">Description</div> | |
| <div class="text-gray-300 leading-relaxed">{{ species.wildlife.description }}</div> | |
| </div> | |
| <div v-if="species.wildlife.habitat"> | |
| <div class="text-xs font-semibold text-gray-400 uppercase mb-1">Habitat</div> | |
| <div class="text-gray-300">{{ species.wildlife.habitat }}</div> | |
| </div> | |
| <div v-if="species.wildlife.behavior"> | |
| <div class="text-xs font-semibold text-gray-400 uppercase mb-1">Behavior</div> | |
| <div class="text-gray-300">{{ species.wildlife.behavior }}</div> | |
| </div> | |
| <div v-if="species.wildlife.conservationStatus" class="flex items-center gap-2"> | |
| <div class="text-xs font-semibold text-gray-400 uppercase">Status:</div> | |
| <span | |
| :class="[ | |
| 'px-2 py-1 rounded text-xs font-semibold', | |
| getStatusClass(species.wildlife.conservationStatus) | |
| ]" | |
| > | |
| {{ species.wildlife.conservationStatus }} | |
| </span> | |
| </div> | |
| <div v-if="species.wildlife.safetyInfo"> | |
| <div class="text-xs font-semibold text-gray-400 uppercase mb-1">Safety</div> | |
| <div class="text-gray-300">{{ species.wildlife.safetyInfo }}</div> | |
| </div> | |
| <div v-if="species.wildlife.isDangerous" class="mt-4 px-4 py-3 bg-red-500/20 border border-red-500/50 rounded-xl text-center"> | |
| <div class="text-red-400 font-bold text-sm">⚠️ DANGEROUS TO HUMANS</div> | |
| </div> | |
| </div> | |
| <!-- Footer --> | |
| <div class="mt-4 pt-4 border-t border-white/10 text-xs text-gray-500 text-right"> | |
| {{ formatTime(species.firstSeen) }} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const { createApp } = Vue; | |
| createApp({ | |
| data() { | |
| return { | |
| ws: null, | |
| connectionStatus: 'disconnected', | |
| videoFrame: null, | |
| youtubeUrl: '', | |
| isChangingUrl: false, | |
| urlChangeMessage: null, | |
| stats: { | |
| fps: 0, | |
| detections: 0, | |
| frames: 0 | |
| }, | |
| detectionCache: new Map(), | |
| historyCache: new Map(), | |
| currentTime: Date.now(), | |
| PERSISTENCE_TIME: 10000, | |
| HISTORY_TIME: 600000 | |
| }; | |
| }, | |
| computed: { | |
| sortedDetections() { | |
| return Array.from(this.detectionCache.entries()) | |
| .sort((a, b) => b[1].lastSeen - a[1].lastSeen); | |
| }, | |
| sortedSpecies() { | |
| const speciesMap = new Map(); | |
| for (const [trackId, entry] of this.historyCache.entries()) { | |
| const det = entry.detection; | |
| const wildlife = det.wildlife; | |
| if (!wildlife || !wildlife.isAnimal) continue; | |
| const speciesKey = wildlife.scientificName || wildlife.commonName || trackId.toString(); | |
| if (!speciesMap.has(speciesKey)) { | |
| speciesMap.set(speciesKey, { | |
| key: speciesKey, | |
| detection: det, | |
| wildlife: wildlife, | |
| trackIds: [trackId], | |
| firstSeen: entry.firstSeen, | |
| lastSeen: entry.lastSeen | |
| }); | |
| } else { | |
| const existing = speciesMap.get(speciesKey); | |
| existing.trackIds.push(trackId); | |
| if (entry.lastSeen > existing.lastSeen) { | |
| existing.detection = det; | |
| existing.wildlife = wildlife; | |
| existing.lastSeen = entry.lastSeen; | |
| } | |
| if (entry.firstSeen < existing.firstSeen) { | |
| existing.firstSeen = entry.firstSeen; | |
| } | |
| } | |
| } | |
| return Array.from(speciesMap.values()) | |
| .sort((a, b) => b.firstSeen - a.firstSeen); | |
| } | |
| }, | |
| mounted() { | |
| this.initWebSocket(); | |
| this.startCleanupInterval(); | |
| this.updateCurrentTime(); | |
| }, | |
| methods: { | |
| initWebSocket() { | |
| const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| this.ws = new WebSocket(`${protocol}//${window.location.host}/ws`); | |
| this.ws.onopen = () => { | |
| this.connectionStatus = 'connected'; | |
| console.log('WebSocket connected'); | |
| }; | |
| this.ws.onclose = () => { | |
| this.connectionStatus = 'disconnected'; | |
| console.log('WebSocket disconnected'); | |
| setTimeout(() => this.initWebSocket(), 3000); | |
| }; | |
| this.ws.onerror = (error) => { | |
| console.error('WebSocket error:', error); | |
| }; | |
| this.ws.onmessage = (event) => { | |
| const message = JSON.parse(event.data); | |
| if (message.type === 'frame') { | |
| this.videoFrame = 'data:image/jpeg;base64,' + message.data; | |
| } else if (message.type === 'tracking') { | |
| this.updateTrackingInfo(message.data); | |
| } else if (message.type === 'url_change_response') { | |
| this.handleUrlChangeResponse(message); | |
| } | |
| }; | |
| }, | |
| changeYouTubeUrl() { | |
| if (!this.youtubeUrl || this.isChangingUrl) return; | |
| // Validate URL | |
| const urlPattern = /^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\/.+/; | |
| if (!urlPattern.test(this.youtubeUrl)) { | |
| this.urlChangeMessage = { | |
| type: 'error', | |
| text: 'Please enter a valid YouTube URL' | |
| }; | |
| setTimeout(() => { | |
| this.urlChangeMessage = null; | |
| }, 3000); | |
| return; | |
| } | |
| this.isChangingUrl = true; | |
| this.urlChangeMessage = null; | |
| // Send URL change request | |
| if (this.ws && this.ws.readyState === WebSocket.OPEN) { | |
| this.ws.send(JSON.stringify({ | |
| type: 'change_youtube_url', | |
| url: this.youtubeUrl | |
| })); | |
| } else { | |
| this.urlChangeMessage = { | |
| type: 'error', | |
| text: 'WebSocket not connected' | |
| }; | |
| this.isChangingUrl = false; | |
| setTimeout(() => { | |
| this.urlChangeMessage = null; | |
| }, 3000); | |
| } | |
| }, | |
| handleUrlChangeResponse(message) { | |
| this.isChangingUrl = false; | |
| if (message.success) { | |
| this.urlChangeMessage = { | |
| type: 'success', | |
| text: 'Stream URL changed successfully!' | |
| }; | |
| // Clear video frame to show loading state | |
| this.videoFrame = null; | |
| } else { | |
| this.urlChangeMessage = { | |
| type: 'error', | |
| text: message.error || 'Failed to change stream URL' | |
| }; | |
| } | |
| setTimeout(() => { | |
| this.urlChangeMessage = null; | |
| }, 5000); | |
| }, | |
| updateTrackingInfo(data) { | |
| this.stats.fps = data.fps || 0; | |
| this.stats.detections = data.num_detections || 0; | |
| this.stats.frames = data.frame_count || 0; | |
| const currentTime = Date.now(); | |
| const currentTrackIds = new Set(); | |
| data.detections.forEach((det) => { | |
| if (det.track_id) { | |
| currentTrackIds.add(det.track_id); | |
| // Update detection cache | |
| this.detectionCache.set(det.track_id, { | |
| detection: det, | |
| lastSeen: currentTime, | |
| isLive: true | |
| }); | |
| // Update history cache for identified animals | |
| if (det.wildlife && det.wildlife.isAnimal && det.wildlife_status === 'identified') { | |
| if (!this.historyCache.has(det.track_id)) { | |
| this.historyCache.set(det.track_id, { | |
| detection: det, | |
| firstSeen: currentTime, | |
| lastSeen: currentTime | |
| }); | |
| } else { | |
| const existing = this.historyCache.get(det.track_id); | |
| if (det.thumbnail) { | |
| existing.detection = det; | |
| } else if (existing.detection.thumbnail) { | |
| const savedThumbnail = existing.detection.thumbnail; | |
| existing.detection = det; | |
| existing.detection.thumbnail = savedThumbnail; | |
| } else { | |
| existing.detection = det; | |
| } | |
| existing.lastSeen = currentTime; | |
| } | |
| } | |
| } | |
| }); | |
| // Mark non-visible detections as not live | |
| for (const [trackId, entry] of this.detectionCache.entries()) { | |
| if (!currentTrackIds.has(trackId)) { | |
| entry.isLive = false; | |
| } | |
| } | |
| }, | |
| startCleanupInterval() { | |
| setInterval(() => { | |
| this.currentTime = Date.now(); | |
| let hasChanges = false; | |
| // Clean up detection cache | |
| for (const [trackId, entry] of this.detectionCache.entries()) { | |
| if (this.currentTime - entry.lastSeen > this.PERSISTENCE_TIME) { | |
| this.detectionCache.delete(trackId); | |
| hasChanges = true; | |
| } | |
| } | |
| // Clean up history cache | |
| for (const [trackId, entry] of this.historyCache.entries()) { | |
| if (this.currentTime - entry.firstSeen > this.HISTORY_TIME) { | |
| this.historyCache.delete(trackId); | |
| hasChanges = true; | |
| } | |
| } | |
| }, 1000); | |
| }, | |
| updateCurrentTime() { | |
| setInterval(() => { | |
| this.currentTime = Date.now(); | |
| }, 1000); | |
| }, | |
| formatTime(timestamp) { | |
| const timeSince = this.currentTime - timestamp; | |
| const minutesAgo = Math.floor(timeSince / 60000); | |
| const secondsAgo = Math.floor((timeSince % 60000) / 1000); | |
| if (minutesAgo > 0) { | |
| return `First seen ${minutesAgo}m ${secondsAgo}s ago`; | |
| } | |
| return `First seen ${secondsAgo}s ago`; | |
| }, | |
| getStatusClass(status) { | |
| const statusMap = { | |
| 'LC': 'bg-green-500/20 text-green-400', | |
| 'NT': 'bg-lime-500/20 text-lime-400', | |
| 'VU': 'bg-yellow-500/20 text-yellow-400', | |
| 'EN': 'bg-orange-500/20 text-orange-400', | |
| 'CR': 'bg-red-500/20 text-red-400' | |
| }; | |
| return statusMap[status.toUpperCase()] || 'bg-gray-500/20 text-gray-400'; | |
| } | |
| } | |
| }).mount('#app'); | |
| </script> | |
| </body> | |
| </html> | |