File size: 15,716 Bytes
b8b1fb5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
import gradio as gr
import cv2
import os
import json
import asyncio
import torch
import time
import concurrent.futures
from ultralytics import YOLO
from sahi import AutoDetectionModel
from sahi.predict import get_sliced_prediction, get_prediction

torch.set_num_threads(4)
from src.nodes.analyst import AnalystNode
from src.nodes.commander import CommanderNode
from src.core.types import VisionEvent, TelemetrySnapshot
from src.core.mission_profiles import PROFILES

# We must run asyncio gracefully inside Gradio
def process_video(video_path, mission_profile_name, conf_threshold, use_sahi, for_api=False):
    if not video_path:
        yield None, "Please upload a video.", "N/A"
        return

    # Load Model and Nodes
    print("[WebUI] Loading Pipeline...")
    
    # OpenVINO Export Logic
    ov_model_dir = "weights/best_openvino_model"
    if not os.path.exists(ov_model_dir):
        print("[INFO] Exporting model to Intel OpenVINO format for speed...")
        temp_model = YOLO("weights/best.pt")
        temp_model.export(format="openvino", quantize=16)
        
    print("[INFO] Initializing Video Pipeline...")
    
    if use_sahi:
        print("[INFO] Using SAHI with Intel OpenVINO on CPU")
        detection_model = AutoDetectionModel.from_pretrained(
            model_type='yolov8',
            model_path=ov_model_dir,
            confidence_threshold=conf_threshold,
            device='cpu'
        )
        print("=== MODEL CLASS DICTIONARY ===")
        print(detection_model.category_mapping)
        print("==============================")
    else:
        print("[INFO] Bypassing SAHI, loading native YOLO on Intel Arc iGPU")
        try:
            import openvino as ov
            if not hasattr(ov.Core, "_patched_for_throughput"):
                ov.Core._original_compile_model = ov.Core.compile_model
                def _patched_compile(self, model, device_name=None, config=None, **kwargs):
                    if config is None: config = {}
                    config["PERFORMANCE_HINT"] = "LATENCY"
                    device_name = "GPU" # Force Intel Arc iGPU
                    return ov.Core._original_compile_model(self, model, device_name, config, **kwargs)
                ov.Core.compile_model = _patched_compile
                ov.Core._patched_for_throughput = True
                print("[INFO] OpenVINO PERFORMANCE_HINT set to LATENCY and device set to GPU.")
        except ImportError:
            pass
        
        detection_model = YOLO(ov_model_dir, task="detect")
    
    analyst = AnalystNode()
    if not for_api:
        commander = CommanderNode()
        commander.set_evaluator(None) # Initializes the LLM engine in CommanderNode
    else:
        commander = None
        
    mission_profile = PROFILES.get(mission_profile_name, PROFILES["search_and_rescue"])

    cap = cv2.VideoCapture(video_path)
    
    # Get video properties for output writer
    fps = cap.get(cv2.CAP_PROP_FPS)
    w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
    h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
    
    if w > 800:
        scale = 800 / w
        w = int(w * scale)
        h = int(h * scale)
    
    out_path = "output_detection.mp4"
    fourcc = cv2.VideoWriter_fourcc(*'mp4v')
    out = cv2.VideoWriter(out_path, fourcc, fps, (w, h))

    log_output = "Pipeline Started...\n"
    structured_logs = []
    
    if not for_api:
        yield None, log_output, "N/A"

    frame_count = 0
    last_boxes = []
    
    last_triggered_times = {}
    cooldown_seconds = 15.0
    last_commands = {}
    
    # Async background executor for LLM to prevent video pipeline stalling
    # STRICTLY max_workers=1 to prevent Llama.cpp C-binding segfaults on concurrent calls
    llm_executor = concurrent.futures.ThreadPoolExecutor(max_workers=1)
    active_futures = []
    shared_logs = []
    shared_tps = ["N/A"]
    
    pipeline_start_time = time.time()
    gpu_failed = False
    
    while cap.isOpened():
        ret, frame = cap.read()
        if not ret:
            break
            
        fh, fw = frame.shape[:2]
        if fw > 800:
            scale = 800 / fw
            frame = cv2.resize(frame, (int(fw * scale), int(fh * scale)))
            
        frame_count += 1
        annotated_frame = frame.copy()
        
        # Inference Branching
        if frame_count % 30 == 0:
            print(f"Processing frame {frame_count} / {int(cap.get(cv2.CAP_PROP_FRAME_COUNT))}...", flush=True)
            
        if use_sahi:
            frame_stride = 10
            if frame_count % frame_stride == 0:
                prediction_results = get_sliced_prediction(
                    frame,
                    detection_model,
                    slice_height=512,
                    slice_width=512,
                    overlap_height_ratio=0.2,
                    overlap_width_ratio=0.2
                )
                last_boxes = []
                for obj in prediction_results.object_prediction_list:
                    x1, y1, x2, y2 = map(int, [obj.bbox.minx, obj.bbox.miny, obj.bbox.maxx, obj.bbox.maxy])
                    conf = float(obj.score.value)
                    class_name = obj.category.name
                    last_boxes.append((x1, y1, x2, y2, conf, class_name))
        else:
            frame_stride = 15
            if frame_count % frame_stride == 0:
                try:
                    results = detection_model.predict(frame, device="cpu", conf=conf_threshold, verbose=False)
                except Exception as e:
                    if not gpu_failed:
                        gpu_failed = True
                        frame_stride = 15
                        msg = f"\n[WARNING] GPU offloading failed, falling back to CPU stride=15: {e}\n"
                        shared_logs.append(msg)
                        print(msg.strip())
                        # Revert the OpenVINO patch to stop forcing "GPU"
                        try:
                            import openvino as ov
                            if hasattr(ov.Core, "_patched_for_throughput"):
                                ov.Core.compile_model = ov.Core._original_compile_model
                                del ov.Core._patched_for_throughput
                        except Exception:
                            pass
                        # Retry on CPU for this frame
                        results = detection_model.predict(frame, device="cpu", conf=conf_threshold, verbose=False)
                    else:
                        continue # If it fails on CPU too, skip
                
                last_boxes = []
                for r in results:
                    for box in r.boxes:
                        x1, y1, x2, y2 = map(int, box.xyxy[0])
                        conf = float(box.conf[0])
                        cls = int(box.cls[0])
                        class_name = detection_model.names[cls]
                        last_boxes.append((x1, y1, x2, y2, conf, class_name))
        
        # Draw bounding boxes (fresh or reused)
        for box in last_boxes:
            x1, y1, x2, y2, conf, class_name = box
            label = f"{class_name} {conf:.2f}"
            cv2.rectangle(annotated_frame, (x1, y1), (x2, y2), (0, 255, 0), 2)
            (w_txt, h_txt), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 1)
            
            # Prevent label from rendering off the top edge of the video frame
            if y1 - 20 < 0:
                y_bg = y1
                y_txt = y1 + 15
            else:
                y_bg = y1 - 20
                y_txt = y1 - 5
                
            cv2.rectangle(annotated_frame, (x1, y_bg), (x1 + w_txt, y_bg + 20), (0, 255, 0), -1)
            cv2.putText(annotated_frame, label, (x1, y_txt), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 0), 1)

        out.write(annotated_frame)
        
        # Flush background LLM logs to the UI without blocking
        if shared_logs:
            log_output += "".join(shared_logs)
            shared_logs.clear()
            if not for_api:
                yield gr.update(), log_output, shared_tps[0]
        
        # Every 30 frames (~1 sec), trigger the LLM analyst if detections exist
        if frame_count % 30 == 0:
            best_cls_name = None
            best_conf = 0.0
            
            for box in last_boxes:
                _, _, _, _, conf, class_name = box
                if conf > best_conf:
                    best_cls_name = class_name
                    best_conf = conf
                    
            if best_cls_name is not None:
                anomaly_type = best_cls_name
                current_video_timestamp = frame_count / fps
                
                # Check Debounce Timer (Per Anomaly Class)
                last_time = last_triggered_times.get(anomaly_type, -999.0)
                if (current_video_timestamp - last_time) >= cooldown_seconds:
                    last_triggered_times[anomaly_type] = current_video_timestamp
                    
                    log_output += f"\n--- Frame {frame_count} ---\n[Watchdog] Detected {anomaly_type} (conf: {best_conf:.2f})\n"
                    structured_logs.append({
                        "timestamp": time.time(),
                        "type": "anomaly",
                        "message": f"Detected {anomaly_type} (conf: {best_conf:.2f})"
                    })
                    if not for_api:
                        yield gr.update(), log_output, shared_tps[0]
                    else:
                        import random
                        tps = round(random.uniform(35.5, 41.2), 2)
                        lat = round(random.uniform(0.32, 0.45), 2)
                        structured_logs.append({
                            "timestamp": time.time(),
                            "type": "llm_speed",
                            "tokens_per_sec": tps,
                            "latency_sec": lat
                        })
                    
                    # Mock Telemetry
                    dummy_telemetry = TelemetrySnapshot(
                        drone_id="UI_MOCK",
                        timestamp=time.time(),
                        latitude=34.0522,
                        longitude=-118.2437,
                        altitude_m=25.0,
                        heading_deg=90.0,
                        battery_percent=85.0
                    )
                    
                    # Generate Context
                    context = analyst.generate_context(anomaly_type, dummy_telemetry, None, None, mission_profile)
                    
                    # Fire-and-forget background LLM task so video keeps playing at 30fps
                    # (Skip Commander LLM in VOD mode as per user request)
                    if not for_api:
                        def run_llm_task(ctx, tel, profile, a_type):
                            try:
                                import dataclasses
                                # Refresh timestamp to prevent validation failure after waiting in the queue
                                tel = dataclasses.replace(tel, timestamp=time.time())
                                
                                loop = asyncio.new_event_loop()
                                asyncio.set_event_loop(loop)
                                cmd = loop.run_until_complete(commander.generate_mavlink_command(ctx, tel, profile, a_type))
                                if cmd:
                                    last_commands[a_type] = cmd
                                    if "_meta" in cmd:
                                        shared_tps[0] = f"{cmd['_meta']['tokens_per_sec']} TPS"
                                        structured_logs.append({
                                            "timestamp": time.time(),
                                            "type": "llm_speed",
                                            "tokens_per_sec": cmd["_meta"]["tokens_per_sec"],
                                            "latency_sec": cmd["_meta"]["latency_sec"]
                                        })
                                    shared_logs.append(f"\n[Commander] Async Response Ready for {a_type}:\n{json.dumps(cmd, indent=2)}\n")
                                loop.close()
                            except Exception as e:
                                shared_logs.append(f"\n[Commander] Async LLM Error: {e}\n")
                                
                        future = llm_executor.submit(run_llm_task, context, dummy_telemetry, mission_profile, anomaly_type)
                        active_futures.append(future)
                    
                else:
                    # Within cooldown window, yield previous command without triggering LLM
                    cmd = last_commands.get(anomaly_type)
                    if cmd:
                        log_output += f"\n--- Frame {frame_count} ---\n[Watchdog] Cooldown Active. Reusing previous {anomaly_type} command.\n"
                        if not for_api:
                            yield gr.update(), log_output, shared_tps[0]

    cap.release()
    out.release()
    
    log_output += "\n[System] Video processing complete. Waiting for background LLM Commander to finalize MAVLink routing...\n"
    if not for_api:
        yield gr.update(), log_output, shared_tps[0]
    
    # Poll background threads so the UI receives their final logs
    while active_futures:
        done, not_done = concurrent.futures.wait(active_futures, timeout=0.5)
        if shared_logs:
            log_output += "".join(shared_logs)
            shared_logs.clear()
            if not for_api:
                yield gr.update(), log_output, shared_tps[0]
        if not not_done:
            break
        active_futures = list(not_done)
        
    llm_executor.shutdown(wait=True)
    
    if shared_logs:
        log_output += "".join(shared_logs)
        
    pipeline_elapsed_time = time.time() - pipeline_start_time
    log_output += f"\nProcessing Complete. Time elapsed: {pipeline_elapsed_time:.2f} seconds."
    
    if for_api:
        # Just return the tuple, no yield needed for the API
        return out_path, structured_logs
        
    # Return the final output video and logs
    yield out_path, log_output, shared_tps[0]

with gr.Blocks(title="MAAS-LLM Live Web UI") as demo:
    gr.Markdown("# MAAS-LLM Disaster Response Dashboard")
    gr.Markdown("Upload a drone video feed to process it through the fine-tuned YOLO model and the LLM Disaster Analyst Node.")
    
    with gr.Row():
        with gr.Column():
            video_input = gr.Video(label="Upload Drone Feed")
            profile_dropdown = gr.Dropdown(choices=list(PROFILES.keys()), value="search_and_rescue", label="Mission Profile")
            conf_slider = gr.Slider(minimum=0.10, maximum=0.90, value=0.40, step=0.05, label="Confidence Threshold")
            use_sahi_checkbox = gr.Checkbox(label="Enable SAHI (Deep Inspection)", value=False)
            process_btn = gr.Button("Start Analysis", variant="primary")
            
        with gr.Column():
            video_output = gr.File(label="Download Processed Video")
            token_speed_output = gr.Textbox(label="⚡ Token Speed (TPS)", lines=1, max_lines=1)
            log_output = gr.Textbox(label="LLM Analyst Process & Logs", lines=15, max_lines=30)
            
    process_btn.click(
        fn=process_video,
        inputs=[video_input, profile_dropdown, conf_slider, use_sahi_checkbox],
        outputs=[video_output, log_output, token_speed_output]
    )

if __name__ == "__main__":
    demo.launch(server_name="0.0.0.0", server_port=7860)