animal-tracking-v2 / index.html
pvanand's picture
Upload 14 files
d3f35ed verified
<!DOCTYPE html>
<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>