shivamsshhiivvaamm commited on
Commit
28cfaab
·
verified ·
1 Parent(s): 9d5bbfb

Upload 3 files

Browse files
Files changed (3) hide show
  1. bytetrack_yolox.py +174 -0
  2. main.py +222 -0
  3. webcam.html +381 -0
bytetrack_yolox.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ByteTrack wrapper using official YOLOX implementation.
3
+ This provides object tracking capabilities for the detection system.
4
+ """
5
+ import numpy as np
6
+ from collections import namedtuple
7
+
8
+ try:
9
+ from yolox.tracker.byte_tracker import BYTETracker, STrack
10
+ YOLOX_AVAILABLE = True
11
+ except ImportError:
12
+ YOLOX_AVAILABLE = False
13
+ print("Warning: YOLOX not available. Falling back to simple tracking.")
14
+
15
+
16
+ # Simple detection object for ByteTrack
17
+ Detection = namedtuple('Detection', ['tlwh', 'score', 'class_id', 'class_name'])
18
+
19
+
20
+ class ByteTrackYOLOX:
21
+ """
22
+ Wrapper for YOLOX ByteTrack implementation.
23
+ Converts YOLO detections to ByteTrack format and back.
24
+ """
25
+
26
+ def __init__(self, fps=30, track_thresh=0.5, track_buffer=30, match_thresh=0.8):
27
+ """
28
+ Initialize ByteTrack tracker.
29
+
30
+ Args:
31
+ fps: Frame rate of the video
32
+ track_thresh: Detection confidence threshold for tracking
33
+ track_buffer: Number of frames to keep lost tracks
34
+ match_thresh: Matching threshold for data association
35
+ """
36
+ self.fps = fps
37
+ self.track_thresh = track_thresh
38
+ self.track_buffer = track_buffer
39
+ self.match_thresh = match_thresh
40
+
41
+ if YOLOX_AVAILABLE:
42
+ # Create BYTETracker arguments
43
+ class Args:
44
+ def __init__(self):
45
+ self.track_thresh = track_thresh
46
+ self.track_buffer = track_buffer
47
+ self.match_thresh = match_thresh
48
+ self.mot20 = False # Use standard MOT17 settings
49
+
50
+ args = Args()
51
+ self.tracker = BYTETracker(args, frame_rate=fps)
52
+ print(f"✓ ByteTrack initialized (YOLOX) - FPS: {fps}, Track Thresh: {track_thresh}")
53
+ else:
54
+ # Fallback to simple tracking
55
+ self.tracker = None
56
+ self.tracks = []
57
+ self.next_id = 1
58
+ print("✓ Simple tracker initialized (YOLOX not available)")
59
+
60
+ def update(self, detections):
61
+ """
62
+ Update tracker with new detections.
63
+
64
+ Args:
65
+ detections: List of detection dicts with keys: 'box', 'confidence', 'class', 'id'
66
+ box format: [x1, y1, x2, y2]
67
+
68
+ Returns:
69
+ Updated detections list with 'track_id' added to each detection
70
+ """
71
+ if not detections:
72
+ return detections
73
+
74
+ if YOLOX_AVAILABLE and self.tracker is not None:
75
+ return self._update_yolox(detections)
76
+ else:
77
+ return self._update_simple(detections)
78
+
79
+ def _update_yolox(self, detections):
80
+ """Update using official YOLOX ByteTrack."""
81
+ # Convert detections to ByteTrack format
82
+ # ByteTrack expects: [x1, y1, x2, y2, score]
83
+ det_array = []
84
+ for det in detections:
85
+ x1, y1, x2, y2 = det['box']
86
+ score = det['confidence']
87
+ det_array.append([x1, y1, x2, y2, score])
88
+
89
+ det_array = np.array(det_array) if det_array else np.empty((0, 5))
90
+
91
+ # Update tracker
92
+ online_targets = self.tracker.update(det_array, [640, 640], [640, 640])
93
+
94
+ # Map track IDs back to detections
95
+ # Match based on IoU
96
+ for det in detections:
97
+ det['track_id'] = None
98
+
99
+ for track in online_targets:
100
+ # Get track bounding box
101
+ tlwh = track.tlwh
102
+ track_box = [tlwh[0], tlwh[1], tlwh[0] + tlwh[2], tlwh[1] + tlwh[3]]
103
+ track_id = track.track_id
104
+
105
+ # Find best matching detection
106
+ best_iou = 0
107
+ best_det = None
108
+ for det in detections:
109
+ iou = self._compute_iou(det['box'], track_box)
110
+ if iou > best_iou:
111
+ best_iou = iou
112
+ best_det = det
113
+
114
+ # Assign track ID if good match
115
+ if best_det is not None and best_iou > 0.3:
116
+ best_det['track_id'] = track_id
117
+
118
+ return detections
119
+
120
+ def _update_simple(self, detections):
121
+ """Simple fallback tracking using IoU matching."""
122
+ # Assign sequential IDs to new detections
123
+ for det in detections:
124
+ # Simple: just assign new IDs each time
125
+ # In a real implementation, we'd match with previous frame
126
+ det['track_id'] = self.next_id
127
+ self.next_id += 1
128
+
129
+ return detections
130
+
131
+ def _compute_iou(self, box1, box2):
132
+ """Compute IoU between two boxes [x1, y1, x2, y2]."""
133
+ x1_min, y1_min, x1_max, y1_max = box1
134
+ x2_min, y2_min, x2_max, y2_max = box2
135
+
136
+ # Intersection area
137
+ inter_x_min = max(x1_min, x2_min)
138
+ inter_y_min = max(y1_min, y2_min)
139
+ inter_x_max = min(x1_max, x2_max)
140
+ inter_y_max = min(y1_max, y2_max)
141
+
142
+ inter_width = max(0, inter_x_max - inter_x_min)
143
+ inter_height = max(0, inter_y_max - inter_y_min)
144
+ inter_area = inter_width * inter_height
145
+
146
+ # Union area
147
+ box1_area = (x1_max - x1_min) * (y1_max - y1_min)
148
+ box2_area = (x2_max - x2_min) * (y2_max - y2_min)
149
+ union_area = box1_area + box2_area - inter_area
150
+
151
+ # IoU
152
+ if union_area == 0:
153
+ return 0
154
+ return inter_area / union_area
155
+
156
+ def reset(self):
157
+ """Reset the tracker."""
158
+ if YOLOX_AVAILABLE and self.tracker is not None:
159
+ # Re-initialize tracker
160
+ class Args:
161
+ def __init__(self, track_thresh, track_buffer, match_thresh):
162
+ self.track_thresh = track_thresh
163
+ self.track_buffer = track_buffer
164
+ self.match_thresh = match_thresh
165
+ self.mot20 = False
166
+
167
+ args = Args(self.track_thresh, self.track_buffer, self.match_thresh)
168
+ self.tracker = BYTETracker(args, frame_rate=self.fps)
169
+ else:
170
+ self.next_id = 1
171
+
172
+
173
+ # Alias for backward compatibility
174
+ ByteTrackWrapper = ByteTrackYOLOX
main.py ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cv2
2
+ import numpy as np
3
+ import onnxruntime as ort
4
+ import shutil
5
+ import os
6
+ import uuid
7
+ import base64
8
+ import time
9
+ import json
10
+ from fastapi import FastAPI, UploadFile, File, Request
11
+ from fastapi.responses import HTMLResponse, JSONResponse
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+ from fastapi.staticfiles import StaticFiles
14
+
15
+ # Import the tracker (YOLOX-based)
16
+ from bytetrack_yolox import ByteTrackWrapper
17
+
18
+ # ---------------- CONFIGURATION ---------------- #
19
+ YOLO_MODEL_PATH = "best.onnx"
20
+
21
+ app = FastAPI()
22
+
23
+ app.add_middleware(
24
+ CORSMiddleware,
25
+ allow_origins=["*"],
26
+ allow_methods=["*"],
27
+ allow_headers=["*"],
28
+ )
29
+
30
+ os.makedirs("static", exist_ok=True)
31
+ app.mount("/static", StaticFiles(directory="static"), name="static")
32
+
33
+ # ---------------- YOLO MODEL ---------------- #
34
+ class YOLO:
35
+ def __init__(self, model_path):
36
+ self.session = ort.InferenceSession(model_path)
37
+ self.input_name = self.session.get_inputs()[0].name
38
+ self.h, self.w = self.session.get_inputs()[0].shape[2:]
39
+ self.conf = 0.50
40
+ self.iou = 0.45
41
+ self.classes = [
42
+ "Zebra", "Lion", "Leopard", "Cheetah", "Tiger", "Bear", "Butterfly",
43
+ "Canary", "Crocodile", "Bull", "Camel", "Centipede", "Caterpillar",
44
+ "Duck", "Squirrel", "Spider", "Ladybug", "Elephant", "Horse", "Fox",
45
+ "Tortoise", "Frog", "Kangaroo", "Deer", "Eagle", "Monkey", "Snake",
46
+ "Owl", "Swan", "Goat", "Rabbit", "Giraffe", "Goose", "PolarBear",
47
+ "Raven", "Hippopotamus", "BrownBear", "Rhinoceros", "Woodpecker",
48
+ "Sheep", "Magpie", "Ostrich", "Jaguar", "Hedgehog", "Turkey",
49
+ "Raccoon", "Worm", "Harbor", "Panda", "RedPanda", "Otter", "Lynx",
50
+ "Scorpion", "Koala"
51
+ ]
52
+ np.random.seed(42)
53
+ # Generate a large palette of random colors for Tracks
54
+ self.colors = np.random.randint(0, 255, size=(200, 3)).tolist()
55
+
56
+ def preprocess(self, img):
57
+ h0, w0 = img.shape[:2]
58
+ scale = min(self.w / w0, self.h / h0)
59
+ nw, nh = int(w0 * scale), int(h0 * scale)
60
+ resized = cv2.resize(img, (nw, nh))
61
+ canvas = np.full((self.h, self.w, 3), 114, dtype=np.uint8)
62
+ canvas[:nh, :nw] = resized
63
+ img = cv2.cvtColor(canvas, cv2.COLOR_BGR2RGB)
64
+ img = img.transpose(2, 0, 1).astype(np.float32) / 255.0
65
+ img = np.expand_dims(img, 0)
66
+ return img, scale
67
+
68
+ def postprocess(self, output, scale):
69
+ preds = output[0][0].transpose()
70
+ boxes, scores, ids = [], [], []
71
+ for p in preds:
72
+ x,y,w,h = p[:4]
73
+ cls_scores = p[4:]
74
+ cid = int(np.argmax(cls_scores))
75
+ score = cls_scores[cid]
76
+ if score >= self.conf:
77
+ x1 = (x - w/2) / scale
78
+ y1 = (y - h/2) / scale
79
+ x2 = (x + w/2) / scale
80
+ y2 = (y + h/2) / scale
81
+ boxes.append([float(x1),float(y1),float(x2),float(y2)])
82
+ scores.append(float(score))
83
+ ids.append(cid)
84
+ results = []
85
+ idxs = cv2.dnn.NMSBoxes(boxes, scores, self.conf, self.iou)
86
+ if len(idxs) > 0:
87
+ for i in idxs.flatten():
88
+ results.append({
89
+ "class": self.classes[ids[i]],
90
+ "confidence": scores[i],
91
+ "box": boxes[i],
92
+ "id": ids[i]
93
+ })
94
+ return results
95
+
96
+ def draw(self, img, detections):
97
+ for d in detections:
98
+ x1,y1,x2,y2 = map(int, d["box"])
99
+
100
+ # Use Track ID for color if available, otherwise Class ID
101
+ track_id = d.get('track_id')
102
+ if track_id is not None:
103
+ # Color based on Track ID (consistent color for same object)
104
+ color_idx = int(track_id) % len(self.colors)
105
+ label = f"{d['class']} #{track_id}"
106
+ else:
107
+ # Fallback to Class ID
108
+ color_idx = int(d["id"]) % len(self.colors)
109
+ label = f"{d['class']} ({d['confidence']:.2f})"
110
+
111
+ color = self.colors[color_idx]
112
+ color = (int(color[0]), int(color[1]), int(color[2]))
113
+
114
+ cv2.rectangle(img, (x1,y1), (x2,y2), color, 3)
115
+
116
+ # Label background
117
+ (w, h), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 2)
118
+ cv2.rectangle(img, (x1, y1 - 25), (x1 + w, y1), color, -1)
119
+ cv2.putText(img, label, (x1, y1-8), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255,255,255), 2)
120
+ return img
121
+
122
+ # Initialize YOLO and Tracker
123
+ yolo = YOLO(YOLO_MODEL_PATH)
124
+ tracker = ByteTrackWrapper(fps=30, track_thresh=0.5, match_thresh=0.8)
125
+
126
+ # ---------------- ROUTES ---------------- #
127
+
128
+ @app.post("/detect", response_class=HTMLResponse)
129
+ async def detect_image(file: UploadFile = File(...)):
130
+ start_t = time.time()
131
+ temp = f"temp_{file.filename}"
132
+ with open(temp, "wb") as f:
133
+ shutil.copyfileobj(file.file, f)
134
+
135
+ img = cv2.imread(temp)
136
+ if img is None:
137
+ return "<h2>Error reading image</h2>"
138
+
139
+ # 1. Inference
140
+ tensor, scale = yolo.preprocess(img)
141
+ output = yolo.session.run(None, {yolo.input_name: tensor})
142
+ detections = yolo.postprocess(output, scale)
143
+
144
+ # 2. Tracking
145
+ # Even on a static upload, we run the tracker to assign IDs.
146
+ tracker.update(detections)
147
+
148
+ # 3. Draw
149
+ img = yolo.draw(img, detections)
150
+
151
+ name = f"output_{uuid.uuid4().hex}.jpg"
152
+ path = f"static/{name}"
153
+ cv2.imwrite(path, img)
154
+
155
+ if os.path.exists(temp):
156
+ os.remove(temp)
157
+
158
+ process_ms = (time.time() - start_t) * 1000
159
+
160
+ return f"""
161
+ <h2>✅ Detection Result</h2>
162
+ <p>⏱️ Processed in {process_ms:.2f}ms</p>
163
+ <div style="margin-bottom: 20px;">
164
+ <img src="/static/{name}" width="800" style="border-radius: 10px; border: 2px solid #333;"/>
165
+ </div>
166
+ <a href="/">⬅ Upload Another</a>
167
+ """
168
+
169
+ @app.post("/detect-frame")
170
+ async def detect_frame(request: Request):
171
+ start_t = time.time()
172
+
173
+ data = await request.json()
174
+ img_data = data.get("image")
175
+ if not img_data:
176
+ return JSONResponse({"error": "No image provided"}, status_code=400)
177
+
178
+ # Decode Image
179
+ try:
180
+ # Splits 'data:image/jpeg;base64,...'
181
+ img_bytes = base64.b64decode(img_data.split(',')[1])
182
+ nparr = np.frombuffer(img_bytes, np.uint8)
183
+ img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
184
+ except Exception as e:
185
+ return JSONResponse({"error": f"Invalid image data: {str(e)}"}, status_code=400)
186
+
187
+ # 1. YOLO Inference
188
+ tensor, scale = yolo.preprocess(img)
189
+ output = yolo.session.run(None, {yolo.input_name: tensor})
190
+ detections = yolo.postprocess(output, scale)
191
+
192
+ # 2. Update Tracker
193
+ # The tracker modifies 'detections' in-place, adding 'track_id' to objects
194
+ tracker.update(detections)
195
+
196
+ # 3. Draw
197
+ img = yolo.draw(img, detections)
198
+
199
+ # Encode back to base64
200
+ _, buffer = cv2.imencode('.jpg', img)
201
+ img_base64 = base64.b64encode(buffer).decode('utf-8')
202
+
203
+ end_t = time.time()
204
+ latency_ms = (end_t - start_t) * 1000
205
+
206
+ return JSONResponse({
207
+ "image": f"data:image/jpeg;base64,{img_base64}",
208
+ "detections": detections,
209
+ "latency_ms": f"{latency_ms:.1f}"
210
+ })
211
+
212
+ @app.get("/", response_class=HTMLResponse)
213
+ def webcam_page():
214
+ if os.path.exists("webcam.html"):
215
+ with open("webcam.html", "r", encoding="utf-8") as f:
216
+ return f.read()
217
+ else:
218
+ return "<h1>Error: webcam.html not found. Please create it.</h1>"
219
+
220
+ if __name__ == "__main__":
221
+ import uvicorn
222
+ uvicorn.run(app, host="0.0.0.0", port=8000)
webcam.html ADDED
@@ -0,0 +1,381 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>Live Wildlife AI</title>
8
+ <style>
9
+ * {
10
+ margin: 0;
11
+ padding: 0;
12
+ box-sizing: border-box;
13
+ }
14
+
15
+ body {
16
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
17
+ background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
18
+ min-height: 100vh;
19
+ display: flex;
20
+ flex-direction: column;
21
+ align-items: center;
22
+ padding: 20px;
23
+ color: #333;
24
+ }
25
+
26
+ .container {
27
+ background: white;
28
+ border-radius: 20px;
29
+ padding: 30px;
30
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
31
+ max-width: 1000px;
32
+ width: 100%;
33
+ }
34
+
35
+ h1 {
36
+ text-align: center;
37
+ color: #2c3e50;
38
+ margin-bottom: 20px;
39
+ }
40
+
41
+ .video-container {
42
+ position: relative;
43
+ width: 100%;
44
+ border-radius: 15px;
45
+ overflow: hidden;
46
+ background: #000;
47
+ box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
48
+ min-height: 400px;
49
+ }
50
+
51
+ #videoElement,
52
+ #canvasElement {
53
+ width: 100%;
54
+ display: block;
55
+ }
56
+
57
+ #canvasElement {
58
+ display: none;
59
+ }
60
+
61
+ .controls {
62
+ display: flex;
63
+ justify-content: center;
64
+ gap: 15px;
65
+ margin-top: 20px;
66
+ }
67
+
68
+ button {
69
+ padding: 12px 25px;
70
+ font-size: 16px;
71
+ border: none;
72
+ border-radius: 8px;
73
+ cursor: pointer;
74
+ font-weight: 600;
75
+ transition: transform 0.2s;
76
+ }
77
+
78
+ #startBtn {
79
+ background: #28a745;
80
+ color: white;
81
+ }
82
+
83
+ #stopBtn {
84
+ background: #dc3545;
85
+ color: white;
86
+ }
87
+
88
+ button:disabled {
89
+ opacity: 0.5;
90
+ cursor: not-allowed;
91
+ }
92
+
93
+ button:hover:not(:disabled) {
94
+ transform: scale(1.05);
95
+ }
96
+
97
+ /* AI Panel Styling */
98
+ .ai-panel {
99
+ margin-top: 25px;
100
+ background: #f8f9fa;
101
+ border-radius: 12px;
102
+ overflow: hidden;
103
+ display: none;
104
+ /* Hidden by default */
105
+ box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
106
+ animation: slideUp 0.5s ease;
107
+ }
108
+
109
+ .ai-header {
110
+ background: #007bff;
111
+ color: white;
112
+ padding: 10px 20px;
113
+ font-weight: bold;
114
+ display: flex;
115
+ justify-content: space-between;
116
+ align-items: center;
117
+ }
118
+
119
+ .ai-body {
120
+ padding: 20px;
121
+ display: grid;
122
+ grid-template-columns: 1fr 1fr;
123
+ gap: 15px;
124
+ }
125
+
126
+ .info-card {
127
+ background: white;
128
+ padding: 10px;
129
+ border-radius: 8px;
130
+ border: 1px solid #e0e0e0;
131
+ }
132
+
133
+ .info-label {
134
+ font-size: 0.85em;
135
+ text-transform: uppercase;
136
+ color: #666;
137
+ font-weight: bold;
138
+ margin-bottom: 5px;
139
+ display: block;
140
+ }
141
+
142
+ .info-value {
143
+ font-size: 1.1em;
144
+ color: #333;
145
+ }
146
+
147
+ .full-width {
148
+ grid-column: span 2;
149
+ }
150
+
151
+ .fun-fact {
152
+ background: #fff3cd;
153
+ border: 1px solid #ffeeba;
154
+ color: #856404;
155
+ }
156
+
157
+ @keyframes slideUp {
158
+ from {
159
+ opacity: 0;
160
+ transform: translateY(20px);
161
+ }
162
+
163
+ to {
164
+ opacity: 1;
165
+ transform: translateY(0);
166
+ }
167
+ }
168
+
169
+ .stats-bar {
170
+ margin-top: 15px;
171
+ display: flex;
172
+ justify-content: space-between;
173
+ background: #f8f9fa;
174
+ padding: 10px 20px;
175
+ border-radius: 10px;
176
+ font-size: 0.9em;
177
+ color: #666;
178
+ font-family: monospace;
179
+ }
180
+
181
+ .perf-item {
182
+ font-weight: bold;
183
+ color: #555;
184
+ }
185
+
186
+ .back-link {
187
+ text-align: center;
188
+ margin-top: 20px;
189
+ }
190
+
191
+ .back-link a {
192
+ color: white;
193
+ text-decoration: none;
194
+ opacity: 0.8;
195
+ }
196
+ </style>
197
+ </head>
198
+
199
+ <body>
200
+ <div class="container">
201
+ <h1>🦁 Wildlife AI Explorer</h1>
202
+
203
+ <div class="video-container">
204
+ <video id="videoElement" autoplay playsinline></video>
205
+ <canvas id="canvasElement"></canvas>
206
+ </div>
207
+
208
+ <div class="controls">
209
+ <button id="startBtn">Start Camera</button>
210
+ <button id="stopBtn" disabled>Stop</button>
211
+ </div>
212
+
213
+ <div class="stats-bar">
214
+ <span class="perf-item">Status: <span id="status">Idle</span></span>
215
+ <span class="perf-item">Objects: <span id="detCount">0</span></span>
216
+ <span class="perf-item">Latency: <span id="latency">0</span>ms</span>
217
+ <span class="perf-item">Tracked IDs:
218
+ <span id="trackIds">--</span>
219
+ </span>
220
+ </div>
221
+
222
+ <!-- Structured AI Panel -->
223
+ <div id="aiPanel" class="ai-panel">
224
+ <div class="ai-header">
225
+ <span id="aiTitle">Analysis Result</span>
226
+ <span style="font-size: 0.8em;">Via Gemini</span>
227
+ </div>
228
+ <div class="ai-body">
229
+ <div class="info-card">
230
+ <span class="info-label">Common Name</span>
231
+ <span class="info-value" id="field-name">--</span>
232
+ </div>
233
+ <div class="info-card">
234
+ <span class="info-label">Scientific Name</span>
235
+ <span class="info-value" id="field-scientific" style="font-style: italic;">--</span>
236
+ </div>
237
+ <div class="info-card">
238
+ <span class="info-label">Habitat</span>
239
+ <span class="info-value" id="field-habitat">--</span>
240
+ </div>
241
+ <div class="info-card">
242
+ <span class="info-label">Diet</span>
243
+ <span class="info-value" id="field-diet">--</span>
244
+ </div>
245
+ <div class="info-card">
246
+ <span class="info-label">Danger Level</span>
247
+ <span class="info-value" id="field-danger">--</span>
248
+ </div>
249
+ <div class="info-card full-width fun-fact">
250
+ <span class="info-label">Fun Fact</span>
251
+ <span class="info-value" id="field-fact">--</span>
252
+ </div>
253
+ </div>
254
+ </div>
255
+
256
+ </div>
257
+
258
+ <div class="back-link">
259
+ <a href="/">⬅ Back to Upload Mode</a>
260
+ </div>
261
+
262
+ <script>
263
+ const trackIdsSpan = document.getElementById('trackIds');
264
+ const video = document.getElementById('videoElement');
265
+ const canvas = document.getElementById('canvasElement');
266
+ const ctx = canvas.getContext('2d');
267
+ const startBtn = document.getElementById('startBtn');
268
+ const stopBtn = document.getElementById('stopBtn');
269
+
270
+ const aiPanel = document.getElementById('aiPanel');
271
+ const statusSpan = document.getElementById('status');
272
+ const countSpan = document.getElementById('detCount');
273
+ const latencySpan = document.getElementById('latency');
274
+
275
+ // Fields to populate
276
+ const fieldName = document.getElementById('field-name');
277
+ const fieldScientific = document.getElementById('field-scientific');
278
+ const fieldHabitat = document.getElementById('field-habitat');
279
+ const fieldDiet = document.getElementById('field-diet');
280
+ const fieldDanger = document.getElementById('field-danger');
281
+ const fieldFact = document.getElementById('field-fact');
282
+
283
+ let stream = null;
284
+ let isDetecting = false;
285
+ let animationId = null;
286
+
287
+ startBtn.addEventListener('click', async () => {
288
+ try {
289
+ stream = await navigator.mediaDevices.getUserMedia({
290
+ video: { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: 'environment' }
291
+ });
292
+ video.srcObject = stream;
293
+ video.onloadedmetadata = () => {
294
+ canvas.width = video.videoWidth;
295
+ canvas.height = video.videoHeight;
296
+ isDetecting = true;
297
+
298
+ startBtn.disabled = true;
299
+ stopBtn.disabled = false;
300
+ statusSpan.innerText = "Running";
301
+
302
+ video.style.display = 'none';
303
+ canvas.style.display = 'block';
304
+
305
+ detectFrame();
306
+ };
307
+ } catch (err) {
308
+ alert("Camera Error: " + err.message);
309
+ }
310
+ });
311
+
312
+ stopBtn.addEventListener('click', () => {
313
+ isDetecting = false;
314
+ if (stream) stream.getTracks().forEach(t => t.stop());
315
+ cancelAnimationFrame(animationId);
316
+
317
+ video.style.display = 'block';
318
+ canvas.style.display = 'none';
319
+ startBtn.disabled = false;
320
+ stopBtn.disabled = true;
321
+ statusSpan.innerText = "Stopped";
322
+ aiPanel.style.display = 'none';
323
+ });
324
+
325
+ async function detectFrame() {
326
+ if (!isDetecting) return;
327
+
328
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
329
+ const imageData = canvas.toDataURL('image/jpeg', 0.6);
330
+
331
+ try {
332
+ const res = await fetch('/detect-frame', {
333
+ method: 'POST',
334
+ headers: { 'Content-Type': 'application/json' },
335
+ body: JSON.stringify({ image: imageData })
336
+ });
337
+
338
+ const data = await res.json();
339
+
340
+ // 1. Draw processed image
341
+ const img = new Image();
342
+ img.onload = () => ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
343
+ img.src = data.image;
344
+
345
+ // 2. Stats
346
+ countSpan.innerText = data.detections.length;
347
+ const ids = data.detections
348
+ .filter(d => d.track_id !== undefined)
349
+ .map(d => `${d.class}:${d.track_id}`);
350
+
351
+ trackIdsSpan.innerText = ids.length ? ids.join(", ") : "--";
352
+
353
+ latencySpan.innerText = data.latency_ms || "0";
354
+
355
+ // 3. AI Data (Structured JSON)
356
+ if (data.ai_data && !data.ai_data.error) {
357
+ const info = data.ai_data;
358
+ aiPanel.style.display = 'block';
359
+
360
+ // Populate fields
361
+ fieldName.innerText = info.common_name || "Unknown";
362
+ fieldScientific.innerText = info.scientific_name || "";
363
+ fieldHabitat.innerText = info.habitat || "";
364
+ fieldDiet.innerText = info.diet || "";
365
+ fieldDanger.innerText = info.danger_level || "";
366
+ fieldFact.innerText = info.fun_fact || "";
367
+ }
368
+
369
+ } catch (e) {
370
+ console.error("Frame error:", e);
371
+ }
372
+
373
+ // Loop
374
+ setTimeout(() => {
375
+ animationId = requestAnimationFrame(detectFrame);
376
+ }, 100);
377
+ }
378
+ </script>
379
+ </body>
380
+
381
+ </html>