mlbench123 commited on
Commit
f9ef9e4
Β·
verified Β·
1 Parent(s): fb28b55

Upload cleaning_heatmap.py

Browse files
Files changed (1) hide show
  1. cleaning_heatmap.py +1040 -0
cleaning_heatmap.py ADDED
@@ -0,0 +1,1040 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cv2
2
+ import numpy as np
3
+ import json
4
+ import os
5
+ from datetime import datetime
6
+ import gradio as gr
7
+ from pathlib import Path
8
+ import threading
9
+ import time
10
+ from collections import defaultdict
11
+ import torch
12
+ from ultralytics import YOLO
13
+ import logging
14
+ from typing import Dict, List, Tuple, Optional
15
+ import base64
16
+ from io import BytesIO
17
+ from PIL import Image
18
+ import matplotlib.pyplot as plt
19
+ import matplotlib.cm as cm
20
+ import cv2.legacy as cv2_legacy
21
+ import shutil
22
+ import tempfile
23
+
24
+ # Configure logging
25
+ logging.basicConfig(level=logging.INFO)
26
+ logger = logging.getLogger(__name__)
27
+
28
+ class HygieneMonitor:
29
+ """Professional hygiene monitoring system for catering kitchen surveillance."""
30
+
31
+ def __init__(self, model_path: str, confidence_threshold: float = 0.5):
32
+ """
33
+ Initialize the hygiene monitoring system.
34
+
35
+ Args:
36
+ model_path: Path to the custom YOLO model
37
+ confidence_threshold: Minimum confidence for detections
38
+ """
39
+ self.model_path = model_path
40
+ self.confidence_threshold = confidence_threshold
41
+ self.model = None
42
+ # CHANGED: Replace heatmap_data with red_mask_data and erased_mask_data
43
+ self.red_mask_data = defaultdict(lambda: np.zeros((480, 640), dtype=np.uint8))
44
+ self.erased_mask_data = defaultdict(lambda: np.zeros((480, 640), dtype=np.uint8))
45
+ self.red_mask_created = defaultdict(bool) # FIXED: Add this missing attribute
46
+ self.processing_active = False
47
+ self.current_video_path = None
48
+ self.table_mask = None
49
+ self.detection_history = []
50
+
51
+ # CHANGED: Mask parameters instead of heatmap parameters
52
+ self.mask_intensity = 255 # Full intensity for red mask
53
+ self.gaussian_sigma = 80 # Blur radius for mask smoothing
54
+ self.intensity_threshold = 30 # Threshold for detecting table changes
55
+
56
+ # NEW: Frame difference tracking for detecting table changes
57
+ self.previous_frame = None
58
+ self.table_changed = False
59
+
60
+ # Tracking parameters
61
+ self.tracker = None
62
+ self.tracker_active = False
63
+ self.last_detection_bbox = None
64
+
65
+ # Cleaning status tracking
66
+ self.detection_frames_count = 0
67
+ self.no_detection_frames_count = 0
68
+ self.cleaning_active = False
69
+ self.cleaning_start_threshold = 4 # frames
70
+ self.cleaning_stop_threshold = 10 # frames
71
+
72
+ self._load_model()
73
+
74
+ def _load_model(self) -> None:
75
+ """Load the custom YOLO model."""
76
+ try:
77
+ if not os.path.exists(self.model_path):
78
+ logger.error(f"Model file not found: {self.model_path}")
79
+ return
80
+
81
+ self.model = YOLO(self.model_path)
82
+ logger.info(f"Model loaded successfully from {self.model_path}")
83
+ except Exception as e:
84
+ logger.error(f"Failed to load model: {str(e)}")
85
+ self.model = None
86
+
87
+ def load_table_mask(self, mask_path: str) -> bool:
88
+ """
89
+ Load the binary mask for table areas.
90
+
91
+ Args:
92
+ mask_path: Path to the binary mask image (white = table area)
93
+
94
+ Returns:
95
+ bool: True if mask loaded successfully
96
+ """
97
+ try:
98
+ if not os.path.exists(mask_path):
99
+ logger.error(f"Mask file not found: {mask_path}")
100
+ return False
101
+
102
+ mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE)
103
+ if mask is None:
104
+ logger.error(f"Failed to load mask from {mask_path}")
105
+ return False
106
+
107
+ # Normalize mask to 0-1 range
108
+ self.table_mask = (mask > 128).astype(np.uint8)
109
+ logger.info(f"Table mask loaded successfully: {mask.shape}")
110
+ return True
111
+ except Exception as e:
112
+ logger.error(f"Error loading table mask: {str(e)}")
113
+ return False
114
+
115
+ def create_default_mask(self, height: int, width: int) -> None:
116
+ """Create a default mask covering the entire frame."""
117
+ self.table_mask = np.ones((height, width), dtype=np.uint8)
118
+ logger.info("Using default mask (entire frame)")
119
+
120
+ def detect_hand_with_cloth(self, frame: np.ndarray) -> List[Dict]:
121
+ """
122
+ Detect hands with cloth in the frame.
123
+
124
+ Args:
125
+ frame: Input frame as numpy array
126
+
127
+ Returns:
128
+ List of detection dictionaries with bbox and confidence
129
+ """
130
+ if self.model is None:
131
+ logger.warning("Model not loaded")
132
+ return []
133
+
134
+ try:
135
+ results = self.model(frame, conf=self.confidence_threshold)
136
+ detections = []
137
+
138
+ for result in results:
139
+ boxes = result.boxes
140
+ if boxes is not None:
141
+ for box in boxes:
142
+ confidence = float(box.conf[0])
143
+ bbox = box.xyxy[0].cpu().numpy() # x1, y1, x2, y2
144
+
145
+ detection = {
146
+ 'bbox': bbox.tolist(),
147
+ 'confidence': confidence,
148
+ 'center': [(bbox[0] + bbox[2]) / 2, (bbox[1] + bbox[3]) / 2],
149
+ 'timestamp': datetime.now().isoformat()
150
+ }
151
+ detections.append(detection)
152
+
153
+ return detections
154
+ except Exception as e:
155
+ logger.error(f"Detection error: {str(e)}")
156
+ return []
157
+
158
+ def init_tracker(self, frame: np.ndarray, bbox: List[float]) -> bool:
159
+ """
160
+ Initialize CSRT tracker with detection bbox.
161
+
162
+ Args:
163
+ frame: Current frame
164
+ bbox: Bounding box [x1, y1, x2, y2]
165
+
166
+ Returns:
167
+ bool: True if tracker initialized successfully
168
+ """
169
+ try:
170
+ self.tracker = cv2_legacy.TrackerCSRT_create()
171
+ # Convert bbox format from [x1, y1, x2, y2] to [x, y, w, h]
172
+ x1, y1, x2, y2 = bbox
173
+ tracker_bbox = (int(x1), int(y1), int(x2-x1), int(y2-y1))
174
+
175
+ success = self.tracker.init(frame, tracker_bbox)
176
+ self.tracker_active = success
177
+ self.last_detection_bbox = bbox
178
+
179
+ logger.info(f"Tracker initialized: {success}")
180
+ return success
181
+ except Exception as e:
182
+ logger.error(f"Failed to initialize tracker: {str(e)}")
183
+ return False
184
+
185
+ def update_tracker(self, frame: np.ndarray) -> Optional[Dict]:
186
+ """
187
+ Update tracker and return tracking result.
188
+
189
+ Args:
190
+ frame: Current frame
191
+
192
+ Returns:
193
+ Dict with tracking result or None if tracking failed
194
+ """
195
+ if not self.tracker_active or self.tracker is None:
196
+ return None
197
+
198
+ try:
199
+ success, tracker_bbox = self.tracker.update(frame)
200
+
201
+ if success:
202
+ # Convert back to [x1, y1, x2, y2] format
203
+ x, y, w, h = tracker_bbox
204
+ bbox = [x, y, x + w, y + h]
205
+
206
+ tracking_result = {
207
+ 'bbox': bbox,
208
+ 'confidence': 0.7, # Assign reasonable confidence for tracker
209
+ 'center': [(bbox[0] + bbox[2]) / 2, (bbox[1] + bbox[3]) / 2],
210
+ 'timestamp': datetime.now().isoformat(),
211
+ 'source': 'tracker'
212
+ }
213
+
214
+ return tracking_result
215
+ else:
216
+ # Tracking failed, deactivate tracker
217
+ self.tracker_active = False
218
+ logger.info("Tracking lost")
219
+ return None
220
+
221
+ except Exception as e:
222
+ logger.error(f"Tracker update error: {str(e)}")
223
+ self.tracker_active = False
224
+ return None
225
+
226
+ def update_cleaning_status(self, detections: List[Dict], frame_shape: Tuple[int, int]) -> str:
227
+ """
228
+ Update cleaning status based on detection patterns.
229
+
230
+ Args:
231
+ detections: List of detection dictionaries
232
+ frame_shape: (height, width) of the frame
233
+
234
+ Returns:
235
+ str: Current cleaning status
236
+ """
237
+ height, width = frame_shape[:2]
238
+
239
+ # Check if any detection is in table area
240
+ table_detection = False
241
+ for detection in detections:
242
+ center_x, center_y = detection['center']
243
+ center_x, center_y = int(center_x), int(center_y)
244
+
245
+ if (0 <= center_y < height and 0 <= center_x < width and
246
+ self.table_mask is not None and self.table_mask[center_y, center_x] > 0):
247
+ table_detection = True
248
+ break
249
+
250
+ # Update counters based on detection
251
+ if table_detection:
252
+ self.detection_frames_count += 1
253
+ self.no_detection_frames_count = 0 # Reset no detection counter
254
+ else:
255
+ self.no_detection_frames_count += 1
256
+ self.detection_frames_count = 0 # Reset detection counter
257
+
258
+ # Update cleaning status
259
+ if not self.cleaning_active and self.detection_frames_count >= self.cleaning_start_threshold:
260
+ self.cleaning_active = True
261
+ logger.info("Cleaning started")
262
+ return "CLEANING STARTED"
263
+ elif self.cleaning_active and self.no_detection_frames_count >= self.cleaning_stop_threshold:
264
+ self.cleaning_active = False
265
+ logger.info("Cleaning stopped")
266
+ return "CLEANING STOPPED"
267
+
268
+ # Return current status
269
+ return "CLEANING ACTIVE" if self.cleaning_active else "NO CLEANING"
270
+
271
+ def _draw_professional_status_panel(self, frame: np.ndarray, cleaning_status: str, detection_count: int, tracking_active: bool) -> None:
272
+ """
273
+ Draw professional status panel with gradient background and modern styling.
274
+
275
+ Args:
276
+ frame: Frame to draw on
277
+ cleaning_status: Current cleaning status
278
+ detection_count: Number of detections
279
+ tracking_active: Whether tracking is active
280
+ """
281
+ height, width = frame.shape[:2]
282
+
283
+ # Panel dimensions and position
284
+ panel_width = 380
285
+ panel_height = 120
286
+ panel_x = width - panel_width - 20
287
+ panel_y = 20
288
+
289
+ # Create gradient background overlay
290
+ overlay = frame.copy()
291
+
292
+ # Draw rounded rectangle background with gradient effect
293
+ # Main panel background (dark semi-transparent)
294
+ cv2.rectangle(overlay, (panel_x, panel_y), (panel_x + panel_width, panel_y + panel_height), (20, 20, 20), -1)
295
+
296
+ # Add subtle border
297
+ cv2.rectangle(overlay, (panel_x-2, panel_y-2), (panel_x + panel_width + 2, panel_y + panel_height + 2), (60, 60, 60), 2)
298
+
299
+ # Blend overlay with original frame
300
+ cv2.addWeighted(frame, 0.3, overlay, 0.7, 0, frame)
301
+
302
+ # Status color coding
303
+ if "ACTIVE" in cleaning_status or "STARTED" in cleaning_status:
304
+ status_color = (0, 200, 0) # Green
305
+ status_bg_color = (0, 60, 0) # Dark green
306
+ elif "STOPPED" in cleaning_status:
307
+ status_color = (0, 100, 255) # Orange/Red
308
+ status_bg_color = (0, 30, 80) # Dark red
309
+ else:
310
+ status_color = (128, 128, 128) # Gray
311
+ status_bg_color = (40, 40, 40) # Dark gray
312
+
313
+ # Draw status indicator bar
314
+ status_bar_height = 8
315
+ cv2.rectangle(frame, (panel_x, panel_y), (panel_x + panel_width, panel_y + status_bar_height), status_color, -1)
316
+
317
+ # Title section
318
+ title_y = panel_y + 30
319
+ cv2.putText(frame, "HYGIENE MONITOR", (panel_x + 15, title_y),
320
+ cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2)
321
+
322
+ # Main status
323
+ main_status_y = title_y + 30
324
+ status_text = cleaning_status.replace("_", " ")
325
+ cv2.putText(frame, status_text, (panel_x + 15, main_status_y),
326
+ cv2.FONT_HERSHEY_DUPLEX, 0.8, status_color, 2)
327
+
328
+ # Add timestamp
329
+ timestamp = datetime.now().strftime("%H:%M:%S")
330
+ cv2.putText(frame, timestamp, (panel_x + panel_width - 80, panel_y + panel_height - 10),
331
+ cv2.FONT_HERSHEY_SIMPLEX, 0.4, (150, 150, 150), 1)
332
+
333
+ # Add detection/tracking indicators (small dots)
334
+ if detection_count > 0:
335
+ cv2.circle(frame, (panel_x + 350, title_y - 5), 4, (0, 255, 0), -1) # Green dot for detection
336
+
337
+ if tracking_active:
338
+ cv2.circle(frame, (panel_x + 365, title_y - 5), 4, (255, 255, 0), -1)
339
+
340
+ def draw_overlays(self, frame: np.ndarray, detections: List[Dict], tracker_result: Optional[Dict], cleaning_status: str) -> np.ndarray:
341
+ """
342
+ Draw professional status overlay on frame (no bounding boxes).
343
+
344
+ Args:
345
+ frame: Input frame
346
+ detections: List of detections (for counting only)
347
+ tracker_result: Tracker result if available (for counting only)
348
+ cleaning_status: Current cleaning status
349
+
350
+ Returns:
351
+ Frame with professional status overlay
352
+ """
353
+ result_frame = frame.copy()
354
+ height, width = result_frame.shape[:2]
355
+
356
+ # Create professional status panel
357
+ self._draw_professional_status_panel(result_frame, cleaning_status, len(detections), tracker_result is not None)
358
+
359
+ return result_frame
360
+
361
+ # CHANGED: New method to detect table changes using frame difference
362
+ def detect_table_changes(self, current_frame: np.ndarray) -> bool:
363
+ """
364
+ Detect if there are intensity changes on table area.
365
+
366
+ Args:
367
+ current_frame: Current frame
368
+
369
+ Returns:
370
+ bool: True if table changes detected
371
+ """
372
+ if self.previous_frame is None:
373
+ self.previous_frame = current_frame.copy()
374
+ return False
375
+
376
+ # Convert to grayscale for comparison
377
+ current_gray = cv2.cvtColor(current_frame, cv2.COLOR_BGR2GRAY)
378
+ previous_gray = cv2.cvtColor(self.previous_frame, cv2.COLOR_BGR2GRAY)
379
+
380
+ # Calculate frame difference
381
+ diff = cv2.absdiff(current_gray, previous_gray)
382
+
383
+ # Apply table mask to focus only on table area
384
+ if self.table_mask is not None:
385
+ height, width = diff.shape
386
+ if self.table_mask.shape != (height, width):
387
+ table_mask_resized = cv2.resize(self.table_mask, (width, height))
388
+ else:
389
+ table_mask_resized = self.table_mask
390
+
391
+ masked_diff = diff * table_mask_resized
392
+ else:
393
+ masked_diff = diff
394
+
395
+ # Check if changes exceed threshold
396
+ mean_change = np.mean(masked_diff)
397
+ table_changed = mean_change > self.intensity_threshold
398
+
399
+ # Update previous frame
400
+ self.previous_frame = current_frame.copy()
401
+
402
+ return table_changed
403
+
404
+ # CHANGED: Replace update_heatmap with update_red_mask_and_erase
405
+ def update_red_mask_and_erase(self, detections: List[Dict], frame_shape: Tuple[int, int], table_changed: bool) -> None:
406
+ """
407
+ Update red mask based on table changes and erase it based on detections.
408
+
409
+ Args:
410
+ detections: List of detection dictionaries
411
+ frame_shape: (height, width) of the frame
412
+ table_changed: Whether table changes were detected
413
+ """
414
+ height, width = frame_shape[:2]
415
+ video_key = self.current_video_path or "live"
416
+
417
+ # Ensure masks match frame dimensions
418
+ if video_key not in self.red_mask_data:
419
+ self.red_mask_data[video_key] = np.zeros((height, width), dtype=np.uint8)
420
+ self.erased_mask_data[video_key] = np.zeros((height, width), dtype=np.uint8)
421
+
422
+ # Ensure table mask matches frame dimensions
423
+ if self.table_mask is None:
424
+ self.create_default_mask(height, width)
425
+ elif self.table_mask.shape != (height, width):
426
+ self.table_mask = cv2.resize(self.table_mask, (width, height))
427
+
428
+ # STEP 1: If table changes detected OR first detection on empty table, create red mask on table
429
+ if table_changed or (len(detections) > 0 and not self.red_mask_created[video_key]):
430
+ # Apply red mask to entire table area
431
+ self.red_mask_data[video_key] = np.where(
432
+ self.table_mask > 0,
433
+ self.mask_intensity,
434
+ self.red_mask_data[video_key]
435
+ )
436
+ self.red_mask_created[video_key] = True
437
+ logger.info("Red mask applied to table")
438
+
439
+ # STEP 2: Erase red mask where detections occur
440
+ for detection in detections:
441
+ bbox = detection['bbox'] # x1, y1, x2, y2
442
+
443
+ # Calculate 30% inner circular area of bounding box
444
+ bbox_width = bbox[2] - bbox[0]
445
+ bbox_height = bbox[3] - bbox[1]
446
+
447
+ # Use smaller dimension to ensure circular area stays within bbox
448
+ min_dimension = min(bbox_width, bbox_height)
449
+ effective_radius = (min_dimension * 0.5) / 2
450
+
451
+ center_x, center_y = detection['center']
452
+ center_x, center_y = int(center_x), int(center_y)
453
+
454
+ if (0 <= center_y < height and 0 <= center_x < width and
455
+ self.table_mask[center_y, center_x] > 0):
456
+
457
+ # Create Gaussian blob around detection center with limited radius
458
+ y_indices, x_indices = np.ogrid[:height, :width]
459
+ distance_sq = (x_indices - center_x) ** 2 + (y_indices - center_y) ** 2
460
+
461
+ # Use effective radius with much smoother falloff (increased multiplier and minimum)
462
+ gaussian_sigma = max(effective_radius * 2.5, 20)
463
+
464
+ # Create much smoother Gaussian distribution with softer edges
465
+ gaussian_mask = np.exp(-distance_sq / (2 * gaussian_sigma ** 2))
466
+
467
+ # Apply table mask to the gaussian
468
+ masked_gaussian = gaussian_mask * self.table_mask
469
+
470
+ # ERASE red mask where detection occurs with smooth blending
471
+ erase_intensity = masked_gaussian * self.mask_intensity * detection['confidence'] * 3
472
+
473
+ # Update erased mask with smooth blending instead of hard maximum
474
+ current_erased = self.erased_mask_data[video_key].astype(np.float32)
475
+ new_erase = erase_intensity.astype(np.float32)
476
+
477
+ # Smooth blending: use weighted average for overlapping areas
478
+ blended_erase = np.where(current_erased > 0,
479
+ np.maximum(current_erased, current_erased * 0.7 + new_erase * 0.3),
480
+ new_erase)
481
+
482
+ self.erased_mask_data[video_key] = np.clip(blended_erase, 0, 255).astype(np.uint8)
483
+
484
+ # Store detection in history
485
+ self.detection_history.append({
486
+ 'timestamp': detection['timestamp'],
487
+ 'center': [center_x, center_y],
488
+ 'confidence': detection['confidence'],
489
+ 'video': video_key
490
+ })
491
+
492
+ # CHANGED: Replace generate_heatmap_overlay with generate_red_mask_overlay
493
+ def generate_red_mask_overlay(self, frame: np.ndarray, alpha: float = 0.6) -> np.ndarray:
494
+ """
495
+ Generate red mask overlay on the frame, with erased areas removed.
496
+
497
+ Args:
498
+ frame: Original frame
499
+ alpha: Transparency of red mask overlay
500
+
501
+ Returns:
502
+ Frame with red mask overlay
503
+ """
504
+ video_key = self.current_video_path or "live"
505
+ red_mask = self.red_mask_data[video_key]
506
+ erased_mask = self.erased_mask_data[video_key]
507
+
508
+ if red_mask.max() == 0:
509
+ return frame
510
+
511
+ # Apply table mask to red mask
512
+ if self.table_mask is not None:
513
+ # Ensure mask dimensions match
514
+ if self.table_mask.shape != red_mask.shape:
515
+ mask_resized = cv2.resize(self.table_mask.astype(np.uint8),
516
+ (red_mask.shape[1], red_mask.shape[0]))
517
+ else:
518
+ mask_resized = self.table_mask
519
+
520
+ # Apply table mask
521
+ masked_red_mask = red_mask * mask_resized
522
+ else:
523
+ masked_red_mask = red_mask
524
+
525
+ # Subtract erased areas from red mask
526
+ final_red_mask = np.maximum(0, masked_red_mask.astype(np.int16) - erased_mask.astype(np.int16))
527
+ final_red_mask = final_red_mask.astype(np.uint8)
528
+
529
+ if final_red_mask.max() == 0:
530
+ return frame
531
+
532
+ # Create red colored mask
533
+ red_colored_mask = np.zeros_like(frame)
534
+ red_colored_mask[:, :, 2] = final_red_mask # Red channel
535
+
536
+ # Create mask for non-zero areas
537
+ mask = final_red_mask > 5
538
+
539
+ # Blend with original frame
540
+ result = frame.copy()
541
+ result[mask] = cv2.addWeighted(frame[mask], 1-alpha, red_colored_mask[mask], alpha, 0)
542
+
543
+ return result
544
+
545
+ def process_video(self, video_path: str, output_dir: str = "output") -> Dict:
546
+ """
547
+ Process entire video and generate red mask data with tracking and status overlay.
548
+
549
+ Args:
550
+ video_path: Path to input video
551
+ output_dir: Directory for output files
552
+
553
+ Returns:
554
+ Dictionary with processing results
555
+ """
556
+ if not os.path.exists(video_path):
557
+ return {"error": "Video file not found"}
558
+
559
+ self.current_video_path = video_path
560
+ self.processing_active = True
561
+
562
+ # Reset tracking and status variables
563
+ self.tracker = None
564
+ self.tracker_active = False
565
+ self.detection_frames_count = 0
566
+ self.no_detection_frames_count = 0
567
+ self.cleaning_active = False
568
+ self.previous_frame = None # Reset frame difference tracking
569
+
570
+ # Create output directory
571
+ os.makedirs(output_dir, exist_ok=True)
572
+
573
+ cap = cv2.VideoCapture(video_path)
574
+ if not cap.isOpened():
575
+ return {"error": "Failed to open video"}
576
+
577
+ # Get video properties
578
+ fps = int(cap.get(cv2.CAP_PROP_FPS))
579
+ frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
580
+ width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
581
+ height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
582
+
583
+ # Initialize masks for this video
584
+ self.red_mask_data[video_path] = np.zeros((height, width), dtype=np.uint8)
585
+ self.erased_mask_data[video_path] = np.zeros((height, width), dtype=np.uint8)
586
+
587
+ # FIXED: Better output video path and codec handling
588
+ output_video_path = os.path.join(output_dir, f"{Path(video_path).stem}_hygiene_monitor.mp4")
589
+
590
+ # FIXED: Try different codecs for better compatibility
591
+ fourcc_options = [
592
+ cv2.VideoWriter_fourcc(*'mp4v'),
593
+ cv2.VideoWriter_fourcc(*'XVID'),
594
+ cv2.VideoWriter_fourcc(*'MJPG'),
595
+ cv2.VideoWriter_fourcc(*'H264')
596
+ ]
597
+
598
+ out = None
599
+ for fourcc in fourcc_options:
600
+ out = cv2.VideoWriter(output_video_path, fourcc, fps, (width, height))
601
+ if out.isOpened():
602
+ logger.info(f"Video writer initialized with codec: {fourcc}")
603
+ break
604
+ out.release()
605
+
606
+ if out is None or not out.isOpened():
607
+ cap.release()
608
+ return {"error": "Failed to initialize video writer"}
609
+
610
+ # Process frames
611
+ frame_idx = 0
612
+ all_detections = []
613
+
614
+ try:
615
+ logger.info(f"Starting video processing: {frame_count} frames")
616
+
617
+ while cap.isOpened() and self.processing_active:
618
+ ret, frame = cap.read()
619
+ if not ret:
620
+ logger.info(f"End of video reached at frame {frame_idx}")
621
+ break
622
+
623
+ # Detect table changes
624
+ table_changed = self.detect_table_changes(frame)
625
+
626
+ # Detect hands with cloth
627
+ detections = self.detect_hand_with_cloth(frame)
628
+
629
+ # Handle tracking
630
+ tracker_result = None
631
+ all_objects = [] # Combined detections and tracking
632
+
633
+ if detections:
634
+ # We have detections, add them to objects list
635
+ all_objects.extend(detections)
636
+
637
+ # Initialize tracker if not active
638
+ if not self.tracker_active and len(detections) > 0:
639
+ self.init_tracker(frame, detections[0]['bbox']) # Use first detection
640
+
641
+ # Reset no-detection counter since we have detections
642
+ self.tracker_active = False # Prioritize detection over tracking
643
+ else:
644
+ # No detections, try tracking
645
+ if self.tracker_active:
646
+ tracker_result = self.update_tracker(frame)
647
+ if tracker_result:
648
+ all_objects.append(tracker_result)
649
+ elif self.last_detection_bbox is not None:
650
+ # Try to reinitialize tracker from last known position
651
+ self.init_tracker(frame, self.last_detection_bbox)
652
+
653
+ # Update cleaning status
654
+ cleaning_status = self.update_cleaning_status(all_objects, frame.shape)
655
+
656
+ # Update red mask and erase instead of heatmap
657
+ self.update_red_mask_and_erase(all_objects, frame.shape, table_changed)
658
+
659
+ # Generate red mask overlay instead of heatmap
660
+ frame_with_mask = self.generate_red_mask_overlay(frame, alpha=0.4)
661
+
662
+ # Draw status overlay
663
+ final_frame = self.draw_overlays(frame_with_mask, detections, tracker_result, cleaning_status)
664
+
665
+ # FIXED: Ensure frame is properly formatted before writing
666
+ if final_frame is not None and final_frame.shape[0] > 0 and final_frame.shape[1] > 0:
667
+ # Ensure frame dimensions match video writer expectations
668
+ if final_frame.shape[:2] != (height, width):
669
+ final_frame = cv2.resize(final_frame, (width, height))
670
+
671
+ # Write frame to output video
672
+ success = out.write(final_frame)
673
+ if not success and frame_idx < 10: # Only warn for first few frames
674
+ logger.warning(f"Failed to write frame {frame_idx}")
675
+
676
+ # Store frame detections
677
+ frame_detections = {
678
+ 'frame_id': frame_idx,
679
+ 'timestamp': frame_idx / fps,
680
+ 'detections': detections,
681
+ 'tracker_result': tracker_result,
682
+ 'cleaning_status': cleaning_status,
683
+ 'table_changed': bool(table_changed) # Convert to Python bool
684
+ }
685
+ all_detections.append(frame_detections)
686
+
687
+ frame_idx += 1
688
+
689
+ # Progress update
690
+ if frame_idx % 30 == 0:
691
+ progress = (frame_idx / frame_count) * 100
692
+ logger.info(f"Processing progress: {progress:.1f}% ({frame_idx}/{frame_count} frames)")
693
+
694
+ except Exception as e:
695
+ logger.error(f"Error during video processing: {str(e)}")
696
+ return {"error": f"Processing error: {str(e)}"}
697
+
698
+ finally:
699
+ cap.release()
700
+ if out is not None:
701
+ out.release()
702
+ logger.info(f"Video processing completed. Processed {frame_idx} frames")
703
+
704
+ # FIXED: Verify output video was created
705
+ if not os.path.exists(output_video_path):
706
+ return {"error": "Output video file was not created"}
707
+
708
+ # Check if output video has reasonable size
709
+ if os.path.getsize(output_video_path) < 1024: # Less than 1KB
710
+ return {"error": "Output video file is too small - may be corrupted"}
711
+
712
+ # Generate output files
713
+ results = self._save_results(video_path, all_detections, output_dir)
714
+
715
+ # FIXED: Add output video path to results
716
+ results['output_video_path'] = output_video_path
717
+ results['frames_processed'] = frame_idx
718
+
719
+ self.processing_active = False
720
+
721
+ logger.info(f"Final output video saved to: {output_video_path}")
722
+ return results
723
+
724
+
725
+
726
+ def _save_results(self, video_path: str, detections: List[Dict], output_dir: str) -> Dict:
727
+ """Save processing results to files."""
728
+ try:
729
+ video_name = Path(video_path).stem
730
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
731
+
732
+ # FIXED: Get output video path
733
+ output_video_path = os.path.join(output_dir, f"{video_name}_hygiene_monitor.mp4")
734
+
735
+ # Save JSON data
736
+ json_path = os.path.join(output_dir, f"{video_name}_{timestamp}.json")
737
+ results_data = {
738
+ 'video_path': video_path,
739
+ 'output_video_path': output_video_path, # ADDED
740
+ 'processing_timestamp': datetime.now().isoformat(),
741
+ 'total_detections': len([d for frame in detections for d in frame['detections']]),
742
+ 'total_frames': len(detections),
743
+ 'red_mask_stats': {
744
+ 'max_red_intensity': int(self.red_mask_data[video_path].max()),
745
+ 'total_red_area': int(np.sum(self.red_mask_data[video_path] > 0)),
746
+ 'erased_area': int(np.sum(self.erased_mask_data[video_path] > 0)),
747
+ 'remaining_red_area': int(np.sum((self.red_mask_data[video_path] > 0) & (self.erased_mask_data[video_path] == 0)))
748
+ },
749
+ 'frame_detections': detections
750
+ }
751
+
752
+ with open(json_path, 'w') as f:
753
+ json.dump(results_data, f, indent=2)
754
+
755
+ # Save red mask visualization
756
+ mask_path = os.path.join(output_dir, f"{video_name}_{timestamp}_redmask.png")
757
+ self._save_red_mask_image(video_path, mask_path)
758
+
759
+ return {
760
+ 'success': True,
761
+ 'json_path': json_path,
762
+ 'heatmap_path': mask_path,
763
+ 'output_video_path': output_video_path, # ADDED
764
+ 'stats': results_data['red_mask_stats']
765
+ }
766
+
767
+ except Exception as e:
768
+ logger.error(f"Error saving results: {str(e)}")
769
+ return {'error': str(e)}
770
+
771
+ # CHANGED: New method to save red mask image
772
+ def _save_red_mask_image(self, video_key: str, output_path: str) -> None:
773
+ """Save red mask as image file."""
774
+ red_mask = self.red_mask_data[video_key]
775
+ erased_mask = self.erased_mask_data[video_key]
776
+
777
+ if red_mask.max() == 0:
778
+ return
779
+
780
+ # Calculate final mask (red - erased)
781
+ final_mask = np.maximum(0, red_mask.astype(np.int16) - erased_mask.astype(np.int16))
782
+
783
+ plt.figure(figsize=(12, 8))
784
+ plt.imshow(final_mask, cmap='Reds', interpolation='bilinear')
785
+ plt.colorbar(label='Red Mask Intensity (Remaining)')
786
+ plt.title('Table Red Mask (After Cleaning Erasure)')
787
+ plt.axis('off')
788
+ plt.tight_layout()
789
+ plt.savefig(output_path, dpi=300, bbox_inches='tight')
790
+ plt.close()
791
+
792
+ def reset_heatmap(self, video_key: str = None) -> None: # Keep method name for interface compatibility
793
+ """Reset mask data."""
794
+ if video_key:
795
+ self.red_mask_data[video_key] = np.zeros_like(self.red_mask_data[video_key])
796
+ self.erased_mask_data[video_key] = np.zeros_like(self.erased_mask_data[video_key])
797
+ else:
798
+ self.red_mask_data.clear()
799
+ self.erased_mask_data.clear()
800
+ self.detection_history.clear()
801
+ self.red_mask_created.clear()
802
+ self.previous_frame = None
803
+ logger.info("Red mask data reset")
804
+
805
+ # Gradio Interface
806
+ class HygieneMonitorInterface:
807
+ """Professional Gradio interface for the hygiene monitoring system."""
808
+
809
+ def __init__(self):
810
+ self.monitor = None
811
+ self.live_processing = False
812
+ self.live_thread = None
813
+
814
+ def initialize_monitor(self, model_file, confidence: float, mask_file=None) -> str:
815
+ """Initialize the monitoring system with proper file handling."""
816
+ try:
817
+ # FIXED: Handle Gradio file objects properly
818
+ if model_file is None:
819
+ return "❌ Please upload a model file"
820
+
821
+ # Get the file path from Gradio file object
822
+ if hasattr(model_file, 'name'):
823
+ model_path = model_file.name
824
+ else:
825
+ model_path = str(model_file)
826
+
827
+ # Validate model file exists and has correct extension
828
+ if not os.path.exists(model_path):
829
+ return f"❌ Model file not found: {model_path}"
830
+
831
+ if not model_path.lower().endswith(('.pt', '.pth')):
832
+ return "❌ Please upload a valid YOLO model file (.pt or .pth)"
833
+
834
+ # FIXED: Copy model to a safe location to avoid permission issues
835
+ temp_dir = tempfile.gettempdir()
836
+ safe_model_path = os.path.join(temp_dir, f"model_{int(time.time())}.pt")
837
+
838
+ try:
839
+ shutil.copy2(model_path, safe_model_path)
840
+ model_path = safe_model_path
841
+ except Exception as copy_error:
842
+ logger.warning(f"Could not copy model file: {copy_error}. Using original path.")
843
+
844
+ # Initialize monitor with safe path
845
+ self.monitor = HygieneMonitor(model_path, confidence)
846
+
847
+ # Handle mask file if provided
848
+ if mask_file is not None:
849
+ if hasattr(mask_file, 'name'):
850
+ mask_path = mask_file.name
851
+ else:
852
+ mask_path = str(mask_file)
853
+
854
+ if os.path.exists(mask_path):
855
+ success = self.monitor.load_table_mask(mask_path)
856
+ if not success:
857
+ return "⚠️ Model loaded but failed to load table mask. Using default mask."
858
+ else:
859
+ return "⚠️ Model loaded but mask file not found. Using default mask."
860
+
861
+ return "βœ… System initialized successfully!"
862
+
863
+ except Exception as e:
864
+ logger.error(f"Initialization error: {str(e)}")
865
+ return f"❌ Initialization failed: {str(e)}"
866
+
867
+ def process_video_interface(self, video_file, progress=gr.Progress()) -> Tuple[str, str, str]:
868
+ """Process video through Gradio interface with proper error handling."""
869
+ if self.monitor is None:
870
+ return "❌ Please initialize the system first", "", ""
871
+
872
+ if video_file is None:
873
+ return "❌ Please upload a video file", "", ""
874
+
875
+ try:
876
+ progress(0, desc="Starting video processing...")
877
+
878
+ # FIXED: Handle Gradio video file object properly
879
+ if hasattr(video_file, 'name'):
880
+ video_path = video_file.name
881
+ else:
882
+ video_path = str(video_file)
883
+
884
+ # Validate video file
885
+ if not os.path.exists(video_path):
886
+ return f"❌ Video file not found: {video_path}", "", ""
887
+
888
+ # Check if it's a file (not directory)
889
+ if not os.path.isfile(video_path):
890
+ return f"❌ Path is not a file: {video_path}", "", ""
891
+
892
+ # FIXED: Create a safe output directory in temp
893
+ # output_dir = os.path.join(tempfile.gettempdir(), f"hygiene_output_{int(time.time())}")
894
+ output_dir = os.path.join("output/", f"hygiene_output_{int(time.time())}")
895
+ os.makedirs(output_dir, exist_ok=True)
896
+
897
+ # Process video
898
+ results = self.monitor.process_video(video_path, output_dir)
899
+
900
+ if 'error' in results:
901
+ return f"❌ Processing failed: {results['error']}", "", ""
902
+
903
+ progress(1, desc="Processing complete!")
904
+
905
+ # Prepare results
906
+ stats_text = f"""
907
+ πŸ“Š **Processing Results:**
908
+ - JSON Output: {results['json_path']}
909
+ - Red Mask Image: {results['heatmap_path']}
910
+ - Max Red Intensity: {results['stats']['max_red_intensity']}
911
+ - Total Red Area: {results['stats']['total_red_area']} pixels
912
+ - Erased Area: {results['stats']['erased_area']} pixels
913
+ - Remaining Red Area: {results['stats']['remaining_red_area']} pixels
914
+ """
915
+
916
+ return "βœ… Video processed successfully!", stats_text, results['heatmap_path']
917
+
918
+ except Exception as e:
919
+ logger.error(f"Processing error: {str(e)}")
920
+ return f"❌ Processing error: {str(e)}", "", ""
921
+
922
+ def start_live_monitoring(self, camera_index: int = 0) -> str:
923
+ """Start live camera monitoring."""
924
+ if self.monitor is None:
925
+ return "❌ Please initialize the system first"
926
+
927
+ if self.live_processing:
928
+ return "⚠️ Live monitoring already active"
929
+
930
+ self.live_processing = True
931
+ self.live_thread = threading.Thread(target=self._live_monitoring_loop, args=(camera_index,))
932
+ self.live_thread.daemon = True
933
+ self.live_thread.start()
934
+
935
+ return "βœ… Live monitoring started"
936
+
937
+ def stop_live_monitoring(self) -> str:
938
+ """Stop live monitoring."""
939
+ self.live_processing = False
940
+ if self.live_thread:
941
+ self.live_thread.join(timeout=2)
942
+ return "πŸ›‘ Live monitoring stopped"
943
+
944
+ def _live_monitoring_loop(self, camera_index: int) -> None:
945
+ """Live monitoring loop (runs in separate thread)."""
946
+ cap = cv2.VideoCapture(camera_index)
947
+
948
+ try:
949
+ while self.live_processing and cap.isOpened():
950
+ ret, frame = cap.read()
951
+ if not ret:
952
+ continue
953
+
954
+ # Detect table changes
955
+ table_changed = self.monitor.detect_table_changes(frame)
956
+
957
+ # Process frame
958
+ detections = self.monitor.detect_hand_with_cloth(frame)
959
+ self.monitor.update_red_mask_and_erase(detections, frame.shape, table_changed)
960
+
961
+ time.sleep(0.1) # Limit processing rate
962
+ finally:
963
+ cap.release()
964
+
965
+ def create_interface(self) -> gr.Interface:
966
+ """Create the Gradio interface."""
967
+ with gr.Blocks(title="Kitchen Hygiene Monitor", theme=gr.themes.Soft()) as interface:
968
+ gr.Markdown("""
969
+ # 🍽️ Kitchen Hygiene Monitoring System
970
+ Professional AI-powered solution for monitoring table cleaning activities in catering kitchens.
971
+ """)
972
+
973
+ with gr.Tab("πŸ”§ System Setup"):
974
+ gr.Markdown("### Initialize the monitoring system")
975
+
976
+ with gr.Row():
977
+ model_file = gr.File(
978
+ label="Upload YOLO Model (.pt)",
979
+ file_types=[".pt", ".pth"],
980
+ file_count="single"
981
+ )
982
+ mask_file = gr.File(
983
+ label="Upload Table Mask (optional)",
984
+ file_types=[".png", ".jpg", ".jpeg"],
985
+ file_count="single"
986
+ )
987
+
988
+ confidence_slider = gr.Slider(0.1, 1.0, value=0.3, label="Detection Confidence Threshold")
989
+ init_btn = gr.Button("Initialize System", variant="primary")
990
+ init_status = gr.Textbox(label="Status", interactive=False)
991
+
992
+ init_btn.click(
993
+ self.initialize_monitor,
994
+ inputs=[model_file, confidence_slider, mask_file],
995
+ outputs=init_status
996
+ )
997
+
998
+ with gr.Tab("πŸ“Ή Video Processing"):
999
+ gr.Markdown("### Process video files for hygiene analysis")
1000
+
1001
+ video_input = gr.File(
1002
+ label="Upload Video",
1003
+ file_types=[".mp4", ".avi", ".mov", ".mkv"],
1004
+ file_count="single"
1005
+ )
1006
+ process_btn = gr.Button("Process Video", variant="primary")
1007
+
1008
+ with gr.Row():
1009
+ with gr.Column():
1010
+ process_status = gr.Textbox(label="Processing Status", interactive=False)
1011
+ results_text = gr.Markdown(label="Results")
1012
+
1013
+ with gr.Column():
1014
+ heatmap_output = gr.Image(label="Generated Red Mask")
1015
+
1016
+ process_btn.click(
1017
+ self.process_video_interface,
1018
+ inputs=[video_input],
1019
+ outputs=[process_status, results_text, heatmap_output]
1020
+ )
1021
+
1022
+ return interface
1023
+
1024
+ def main():
1025
+ """Main function to run the application."""
1026
+ # Create interface
1027
+ interface_manager = HygieneMonitorInterface()
1028
+ app = interface_manager.create_interface()
1029
+
1030
+ # Launch with appropriate settings for RunPod/production
1031
+ app.launch(
1032
+ server_name="0.0.0.0", # Allow external connections
1033
+ server_port=7860,
1034
+ share=True, # Create shareable link
1035
+ show_error=True,
1036
+ quiet=False
1037
+ )
1038
+
1039
+ if __name__ == "__main__":
1040
+ main()