MuhammadTayyab0143 commited on
Commit
59bcdd1
·
0 Parent(s):

Initial commit: set up project skeleton and deploy workflows

Browse files
.github/workflows/deploy.yml ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Sync to Hugging Face Spaces
2
+
3
+ on:
4
+ push:
5
+ branches: [ main ]
6
+ # Allow manual trigger from the GitHub Actions tab
7
+ workflow_dispatch:
8
+
9
+ jobs:
10
+ deploy:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - name: Checkout Code
14
+ uses: actions/checkout@v4
15
+ with:
16
+ fetch-depth: 0
17
+ lfs: true
18
+
19
+ - name: Push to Hugging Face Spaces
20
+ env:
21
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
22
+ run: |
23
+ git push --force https://hf_user:$HF_TOKEN@huggingface.co/spaces/YOUR_HF_USERNAME/YOUR_SPACE_NAME main
.gitignore ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ env/
8
+ build/
9
+ develop-eggs/
10
+ dist/
11
+ downloads/
12
+ eggs/
13
+ .eggs/
14
+ lib/
15
+ lib64/
16
+ parts/
17
+ sdist/
18
+ var/
19
+ wheels/
20
+ *.egg-info/
21
+ .installed.cfg
22
+ *.egg
23
+ venv/
24
+ .venv/
25
+
26
+ # AI Models
27
+ *.pt
28
+ *.pth
29
+ *.onnx
30
+ *.engine
31
+
32
+ # IDEs & System
33
+ .idea/
34
+ .vscode/
35
+ .project
36
+ .pydevproject
37
+ .DS_Store
38
+ Thumbs.db
39
+
40
+ # Project Outputs & Temp Files
41
+ outputs/
42
+ outputs_annotated/
43
+ *.mp4
44
+ *.avi
45
+ *.mov
46
+ *.png
47
+ *.jpg
48
+ *.jpeg
49
+ *.gif
50
+ *.pdf
51
+ *.docx
52
+ *.txt
53
+ !requirements.txt
54
+
55
+ # Local data & environment variables
56
+ .env
57
+ .env.local
app.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import cv2
3
+ import numpy as np
4
+ import gradio as gr
5
+ import time
6
+ from datetime import datetime
7
+
8
+ # Ensure project module can be imported
9
+ import sys
10
+ sys.path.append(os.path.dirname(os.path.abspath(__file__)))
11
+
12
+ from suspicious_behavior.pipeline.frame_analyzer import FrameAnalyzer
13
+ from suspicious_behavior.pipeline.video_processor import VideoProcessor
14
+ import suspicious_behavior.config as config
15
+
16
+ # Lazy load analyzer to make space startup quick
17
+ analyzer_instance = None
18
+
19
+ def get_analyzer():
20
+ global analyzer_instance
21
+ if analyzer_instance is None:
22
+ print("[Gradio] Initializing FrameAnalyzer...")
23
+ analyzer_instance = FrameAnalyzer(camera_id="demo_cam", violence_stride=8)
24
+ return analyzer_instance
25
+
26
+
27
+ def process_video(video_path, violence_stride, running_threshold, violence_threshold):
28
+ """
29
+ Processes the uploaded video file, overlays annotations, and returns the video + log results.
30
+ """
31
+ if not video_path:
32
+ return None, "No video uploaded."
33
+
34
+ # Update dynamic configurations from UI sliders
35
+ config.RUNNING_CONFIDENCE_THRESHOLD = float(running_threshold) / 100.0
36
+ config.VIOLENCE_CONFIDENCE_THRESHOLD = float(violence_threshold) / 100.0
37
+
38
+ inst = get_analyzer()
39
+ inst.violence_stride = int(violence_stride)
40
+
41
+ # Reset tracker and frame buffer states
42
+ inst.frame_idx = 0
43
+ inst.violence_buffer.clear()
44
+ inst.alert_manager.reset_cooldowns()
45
+
46
+ processor = VideoProcessor(target_fps=10.0)
47
+ metadata = processor.get_metadata(video_path)
48
+
49
+ processed_frames = []
50
+ alert_logs = []
51
+
52
+ start_time = time.time()
53
+
54
+ # Process sampled frames
55
+ for f_idx, frame, timestamp in processor.extract_frames_generator(video_path):
56
+ annotated, alerts, frame_meta = inst.analyze(frame, fps=10.0, output_base64=False)
57
+ processed_frames.append(annotated)
58
+
59
+ # Log any alerts triggered
60
+ for alert in alerts:
61
+ alert_logs.append({
62
+ "time": f"{timestamp:.2f}s",
63
+ "threat": alert.threat_type.upper(),
64
+ "confidence": f"{alert.confidence * 100:.1f}%",
65
+ "severity": alert.severity
66
+ })
67
+
68
+ elapsed_time = time.time() - start_time
69
+ output_path = "annotated_output.mp4"
70
+
71
+ # Compile output frames to video
72
+ processor.write_frames_to_video(
73
+ processed_frames,
74
+ output_path,
75
+ fps=10.0,
76
+ frame_size=(metadata['width'], metadata['height'])
77
+ )
78
+
79
+ summary = (
80
+ f"Analysis complete in {elapsed_time:.1f}s!\n"
81
+ f"Average speed: {len(processed_frames)/elapsed_time:.1f} FPS.\n"
82
+ f"Alerts triggered: {len(alert_logs)}"
83
+ )
84
+
85
+ return output_path, alert_logs or "No alerts triggered in this clip."
86
+
87
+
88
+ def process_image(image_path, running_threshold):
89
+ """
90
+ Processes a single frame. VideoMAE is bypassed (needs 16 frames), but MediaPipe Pose is run.
91
+ """
92
+ if image_path is None:
93
+ return None, "No image uploaded."
94
+
95
+ config.RUNNING_CONFIDENCE_THRESHOLD = float(running_threshold) / 100.0
96
+
97
+ # Load image and convert to OpenCV format
98
+ img = cv2.imread(image_path)
99
+ if img is None:
100
+ return None, "Invalid image file."
101
+
102
+ inst = get_analyzer()
103
+ # Reset single frame state
104
+ inst.frame_idx = 0
105
+
106
+ annotated, alerts, frame_meta = inst.analyze(img, fps=10.0, output_base64=False)
107
+
108
+ alert_list = []
109
+ for alert in alerts:
110
+ alert_list.append({
111
+ "threat": alert.threat_type.upper(),
112
+ "confidence": f"{alert.confidence * 100:.1f}%",
113
+ "severity": alert.severity
114
+ })
115
+
116
+ # Render results metadata
117
+ details = {
118
+ "active_persons": frame_meta["active_tracks_count"],
119
+ "alerts": alert_list,
120
+ "tracks_info": frame_meta["tracks"]
121
+ }
122
+
123
+ # Convert BGR back to RGB for Gradio display
124
+ annotated_rgb = cv2.cvtColor(annotated, cv2.COLOR_BGR2RGB)
125
+ return annotated_rgb, details
126
+
127
+
128
+ # Build the Gradio UI Layout
129
+ with gr.Blocks(theme=gr.themes.Soft(), title="SimShieldAI Suspicious Behavior Console") as demo:
130
+ gr.HTML(
131
+ """
132
+ <div style="text-align: center; margin-bottom: 20px;">
133
+ <h1 style="color: #2D2D2D; font-family: 'Outfit', sans-serif;">SimShieldAI Behavior Console</h1>
134
+ <p style="color: #666; font-size: 16px;">Production-Grade AI Surveillance Security Suite</p>
135
+ </div>
136
+ """
137
+ )
138
+
139
+ with gr.Tabs():
140
+ # --- TAB 1: VIDEO ANALYSIS ---
141
+ with gr.TabItem("Video surveillance stream analyzer"):
142
+ gr.Markdown(
143
+ "Upload a CCTV video clip to test violence/fighting detection (VideoMAE) "
144
+ "and running/sprinting behavior (MediaPipe Pose)."
145
+ )
146
+ with gr.Row():
147
+ with gr.Column(scale=1):
148
+ video_input = gr.Video(label="Upload CCTV video")
149
+
150
+ gr.Markdown("### Inference Parameters")
151
+ violence_stride_slider = gr.Slider(
152
+ minimum=2, maximum=16, value=8, step=1,
153
+ label="VideoMAE analysis stride (Frames)"
154
+ )
155
+ run_threshold_slider = gr.Slider(
156
+ minimum=30, maximum=95, value=70, step=5,
157
+ label="Running detection sensitivity (%)"
158
+ )
159
+ violence_threshold_slider = gr.Slider(
160
+ minimum=30, maximum=95, value=70, step=5,
161
+ label="Violence detection sensitivity (%)"
162
+ )
163
+
164
+ analyze_btn = gr.Button("Analyze surveillance clip", variant="primary")
165
+
166
+ with gr.Column(scale=2):
167
+ video_output = gr.Video(label="Annotated video playback")
168
+ alerts_output = gr.JSON(label="Triggered Alert logs")
169
+
170
+ analyze_btn.click(
171
+ fn=process_video,
172
+ inputs=[
173
+ video_input,
174
+ violence_stride_slider,
175
+ run_threshold_slider,
176
+ violence_threshold_slider
177
+ ],
178
+ outputs=[video_output, alerts_output]
179
+ )
180
+
181
+ # --- TAB 2: IMAGE FRAME ANALYSIS ---
182
+ with gr.TabItem("Single frame analyzer"):
183
+ gr.Markdown(
184
+ "Upload a single CCTV screenshot or picture to test keypoint skeleton extraction "
185
+ "and running/sprinting pose geometry."
186
+ )
187
+ with gr.Row():
188
+ with gr.Column(scale=1):
189
+ image_input = gr.Image(type="filepath", label="Upload frame screenshot")
190
+ img_run_threshold_slider = gr.Slider(
191
+ minimum=30, maximum=95, value=70, step=5,
192
+ label="Running detection sensitivity (%)"
193
+ )
194
+ analyze_img_btn = gr.Button("Analyze frame", variant="primary")
195
+
196
+ with gr.Column(scale=2):
197
+ image_output = gr.Image(label="Annotated frame output")
198
+ details_output = gr.JSON(label="Extraction diagnostics")
199
+
200
+ analyze_img_btn.click(
201
+ fn=process_image,
202
+ inputs=[image_input, img_run_threshold_slider],
203
+ outputs=[image_output, details_output]
204
+ )
205
+
206
+ # --- TAB 3: SYSTEM PARAMETERS & GUIDE ---
207
+ with gr.TabItem("System guidelines"):
208
+ gr.Markdown(
209
+ """
210
+ ## SimShieldAI behavior detection details
211
+
212
+ This testing console serves as the staging deployment for **Milestone 1** of SimShieldAI.
213
+
214
+ ### How the engines operate:
215
+
216
+ 1. **YOLO Person Detection**:
217
+ - Identifies all persons in the frame using YOLOv8n (optimized for real-time edge processing).
218
+ 2. **SORT Object Tracker**:
219
+ - Maintains persistent IDs for each individual across frames. This allows the system to measure movement velocity and avoid triggering duplicate alerts.
220
+ 3. **Violence Detection (VideoMAE)**:
221
+ - Gathers a sliding buffer of 16 frames. Once filled, the VideoMAE crime detector classifies the temporal clip for fighting/assault.
222
+ 4. **Running Detection (MediaPipe Pose)**:
223
+ - Calculates real-time 33-point geometric skeleton models.
224
+ - Applies a weighted kinematics scorecard based on:
225
+ - **Stride angle** (hips-ankles angle)
226
+ - **Torso lean** (angle from vertical axis)
227
+ - **Displacement speed** (center of mass movement velocity)
228
+ - **Knee drive** (height of knee relative to torso length)
229
+
230
+ ### Cooldown Deduplication:
231
+ - The system implements a **60-second cooldown** per behavior per camera. If a fight is detected, only one alert is generated, preventing operators from being flooded.
232
+ """
233
+ )
234
+
235
+ # Run Gradio application
236
+ if __name__ == "__main__":
237
+ demo.launch(server_name="0.0.0.0", server_port=7860)
diagnose_thresholds.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # from pathlib import Path
2
+
3
+ # from ultralytics import YOLO
4
+
5
+
6
+ # root = Path(__file__).resolve().parent
7
+ # model = YOLO(str(root / "model" / "best.pt"))
8
+ # source = root / "samples" / "cctv1image2.jpg"
9
+
10
+ # print(f"Testing {source.name} with very low confidence thresholds")
11
+ # print(f"Model classes: {model.names}")
12
+
13
+ # for conf in [0.01, 0.05, 0.10, 0.15, 0.20, 0.25]:
14
+ # results = model.predict(
15
+ # source=str(source),
16
+ # imgsz=1280,
17
+ # conf=conf,
18
+ # save=False,
19
+ # verbose=False,
20
+ # )
21
+ # boxes = results[0].boxes
22
+ # print(f"\nconf={conf:.2f} detections={len(boxes)}")
23
+ # for box in boxes[:10]:
24
+ # cls_id = int(box.cls[0])
25
+ # score = float(box.conf[0])
26
+ # label = model.names.get(cls_id, str(cls_id))
27
+ # print(f"- {label}: {score:.4f}")
28
+ # import cv2
29
+ # import supervision as sv
30
+ # from inference import get_model
31
+
32
+
33
+ # project = rf.workspace("ddroid6ty").project("weapon-detection-o3pp0")
34
+
35
+ # model = get_model(model_id="ddroid6ty/weapon-detection-o3pp0/1", api_key="u8tcoa3IPDkjPsBa2CvY")
36
+
37
+
38
+
39
+
40
+
41
+
42
+ import cv2
43
+ import numpy as np
44
+ import supervision as sv
45
+ from roboflow import Roboflow
46
+ import json
47
+
48
+ # -----------------------------
49
+ # Load Roboflow model
50
+ # -----------------------------
51
+ rf = Roboflow(api_key="u8tcoa3IPDkjPsBa2CvY")
52
+ project = rf.workspace("ddroid6ty").project("weapon-detection-o3pp0")
53
+ model = project.version(1).model
54
+
55
+
56
+ # -----------------------------
57
+ # Store detections globally (for debugging)
58
+ # -----------------------------
59
+ all_predictions = []
60
+
61
+
62
+ # -----------------------------
63
+ # Callback function
64
+ # -----------------------------
65
+ def callback(image):
66
+ global all_predictions
67
+
68
+ result = model.predict(image, confidence=25).json()
69
+
70
+ xyxy, confidences, class_ids = [], [], []
71
+
72
+ for pred in result["predictions"]:
73
+ x1 = pred["x"] - pred["width"] / 2
74
+ y1 = pred["y"] - pred["height"] / 2
75
+ x2 = pred["x"] + pred["width"] / 2
76
+ y2 = pred["y"] + pred["height"] / 2
77
+
78
+ xyxy.append([x1, y1, x2, y2])
79
+ confidences.append(pred["confidence"])
80
+ class_ids.append(pred["class_id"])
81
+
82
+ # 🔥 Save full debug info
83
+ all_predictions.append(pred)
84
+
85
+ if len(xyxy) == 0:
86
+ return sv.Detections.empty()
87
+
88
+ return sv.Detections(
89
+ xyxy=np.array(xyxy),
90
+ confidence=np.array(confidences),
91
+ class_id=np.array(class_ids)
92
+ )
93
+
94
+
95
+ # -----------------------------
96
+ # SAHI slicer
97
+ # -----------------------------
98
+ slicer = sv.InferenceSlicer(
99
+ callback=callback,
100
+ slice_wh=(640, 640),
101
+ overlap_wh=(100, 100),
102
+ )
103
+
104
+
105
+ # -----------------------------
106
+ # Load image
107
+ # -----------------------------
108
+ image = cv2.imread("cctvimage2.jpg")
109
+
110
+ detections = slicer(image)
111
+
112
+
113
+ # -----------------------------
114
+ # Annotate image
115
+ # -----------------------------
116
+ box_annotator = sv.BoxAnnotator()
117
+
118
+ annotated = box_annotator.annotate(
119
+ scene=image.copy(),
120
+ detections=detections
121
+ )
122
+
123
+ cv2.imwrite("outputs/sahi_result.jpg", annotated)
124
+
125
+
126
+ # -----------------------------
127
+ # PRINT CLEAN RESULTS (IMPORTANT)
128
+ # -----------------------------
129
+ print("\n===== DETECTION SUMMARY =====")
130
+ print(f"Total detections: {len(detections)}\n")
131
+
132
+ for i, pred in enumerate(all_predictions):
133
+ print(f"[{i+1}]")
134
+ print(f" Class : {pred.get('class', 'unknown')}")
135
+ print(f" Confidence : {pred['confidence']*100:.2f}%")
136
+ print(f" Center X : {pred['x']}")
137
+ print(f" Center Y : {pred['y']}")
138
+ print("---------------------------")
139
+
140
+
141
+ # -----------------------------
142
+ # SAVE JSON FILE (VERY USEFUL)
143
+ # -----------------------------
144
+ with open("outputs/detections.json", "w") as f:
145
+ json.dump(all_predictions, f, indent=4)
146
+
147
+ print("\nSaved: outputs/detections.json")
148
+ print("Saved: outputs/sahi_result.jpg")
model/classes.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "1": "Gun",
3
+ "2": "Explosive",
4
+ "3": "Grenade",
5
+ "4": "Knife"
6
+ }
model_results_comparison.md ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Side-by-Side Model Comparison: Roboflow V4 vs. YOLOv11
2
+
3
+ Below is a comparison of the detections on the key CCTV test images. You can see how each model performs on the same scene.
4
+
5
+ ---
6
+
7
+ ## 1. CCTV Image 3 (Client Reported Issue)
8
+ * **Roboflow V4:** Missed the gun at standard thresholds (detected gun at 28% only when padded).
9
+ * **YOLOv11:** Successfully detected the gun at **76% confidence**.
10
+
11
+ ````carousel
12
+ ![Roboflow V4 Result](/C:/Users/Admin/.gemini/antigravity/brain/a6bb55bc-fb11-4b23-93c4-2b7a3eec14f1/roboflow_v4_results/cctvimage3.jpg)
13
+ <!-- slide -->
14
+ ![YOLOv11 Result](/C:/Users/Admin/.gemini/antigravity/brain/a6bb55bc-fb11-4b23-93c4-2b7a3eec14f1/yolov11_results/cctvimage3.jpg)
15
+ ````
16
+
17
+ ---
18
+
19
+ ## 2. CCTV Image 2
20
+ * **Roboflow V4:** Detected both the shooter (89%) and the gun (46%).
21
+ * **YOLOv11:** Detected the gun (49.5%).
22
+
23
+ ````carousel
24
+ ![Roboflow V4 Result](/C:/Users/Admin/.gemini/antigravity/brain/a6bb55bc-fb11-4b23-93c4-2b7a3eec14f1/roboflow_v4_results/cctvimage2.jpg)
25
+ <!-- slide -->
26
+ ![YOLOv11 Result](/C:/Users/Admin/.gemini/antigravity/brain/a6bb55bc-fb11-4b23-93c4-2b7a3eec14f1/yolov11_results/cctvimage2.jpg)
27
+ ````
28
+
29
+ ---
30
+
31
+ ## 3. CCTV Image 6
32
+ * **Roboflow V4:** Detected the shooter (81.4%).
33
+ * **YOLOv11:** Detected the gun (39.6%).
34
+
35
+ ````carousel
36
+ ![Roboflow V4 Result](/C:/Users/Admin/.gemini/antigravity/brain/a6bb55bc-fb11-4b23-93c4-2b7a3eec14f1/roboflow_v4_results/cctvimage6.jpg)
37
+ <!-- slide -->
38
+ ![YOLOv11 Result](/C:/Users/Admin/.gemini/antigravity/brain/a6bb55bc-fb11-4b23-93c4-2b7a3eec14f1/yolov11_results/cctvimage6.jpg)
39
+ ````
40
+
41
+ ---
42
+
43
+ ## 4. CCTV 1 Image
44
+ * **Roboflow V4:** No detections when padded (23% gun on direct).
45
+ * **YOLOv11:** Detected the gun (23%).
46
+
47
+ ````carousel
48
+ ![Roboflow V4 Result](/C:/Users/Admin/.gemini/antigravity/brain/a6bb55bc-fb11-4b23-93c4-2b7a3eec14f1/roboflow_v4_results/cctv1image.jpg)
49
+ <!-- slide -->
50
+ ![YOLOv11 Result](/C:/Users/Admin/.gemini/antigravity/brain/a6bb55bc-fb11-4b23-93c4-2b7a3eec14f1/yolov11_results/cctv1image.jpg)
51
+ ````
read_docx.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import zipfile
2
+ import xml.etree.ElementTree as ET
3
+ import os
4
+ import sys
5
+
6
+ # Set encoding to utf-8 for stdout if possible
7
+ try:
8
+ sys.stdout.reconfigure(encoding='utf-8')
9
+ except AttributeError:
10
+ pass
11
+
12
+ docx_path = r"C:\Users\Admin\Downloads\SimShieldAI_Phase2_Report.docx"
13
+
14
+ if not os.path.exists(docx_path):
15
+ print(f"File not found at: {docx_path}")
16
+ else:
17
+ try:
18
+ with zipfile.ZipFile(docx_path) as docx:
19
+ xml_content = docx.read('word/document.xml')
20
+ root = ET.fromstring(xml_content)
21
+
22
+ # XML namespaces
23
+ namespaces = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
24
+
25
+ paragraphs = []
26
+ for paragraph in root.iter('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}p'):
27
+ texts = [node.text for node in paragraph.iter('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t') if node.text]
28
+ if texts:
29
+ paragraphs.append("".join(texts))
30
+
31
+ content = "\n".join(paragraphs)
32
+
33
+ # Save to txt file
34
+ txt_path = r"c:\Users\Admin\Desktop\testing_model\extracted_text.txt"
35
+ with open(txt_path, "w", encoding="utf-8") as f:
36
+ f.write(content)
37
+
38
+ print(f"Successfully extracted text to: {txt_path}")
39
+
40
+ # Print with errors='replace' to avoid console crashes
41
+ print("\n--- CONTENT OF DOCX ---")
42
+ print(content.encode('ascii', errors='replace').decode('ascii'))
43
+ print("-----------------------\n")
44
+ except Exception as e:
45
+ print(f"Error reading docx: {e}")
requirements.txt ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core AI & Computer Vision
2
+ ultralytics>=8.4.0
3
+ transformers>=4.40.0
4
+ torch
5
+ torchvision
6
+ mediapipe>=0.10.0
7
+ opencv-python-headless
8
+ numpy<2.0.0
9
+ protobuf==4.25.3
10
+
11
+ # Object Tracking
12
+ scipy
13
+ filterpy
14
+
15
+ # API & UI Server
16
+ gradio>=4.0.0
17
+ fastapi
18
+ uvicorn
19
+ python-multipart
20
+ pydantic
suspicious_behavior/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Suspicious Behavior Package
suspicious_behavior/alerts/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Alerts submodule
suspicious_behavior/alerts/alert_manager.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ from datetime import datetime
3
+ from suspicious_behavior.config import ALERT_COOLDOWN_SECONDS, ALERT_SEVERITY_MAPPING
4
+ from suspicious_behavior.alerts.alert_models import AlertPayload
5
+
6
+ class AlertManager:
7
+ """
8
+ Production-grade Alert Deduplication and Event Manager.
9
+ Enforces cooldown rules per camera and behavior to prevent alert flooding.
10
+ """
11
+ def __init__(self, cooldown_seconds=ALERT_COOLDOWN_SECONDS):
12
+ self.cooldown_seconds = cooldown_seconds
13
+ # Stores the last triggered timestamp: {(camera_id, threat_type): float_timestamp}
14
+ self.alert_history = {}
15
+
16
+ def trigger_alert(self, threat_type, confidence, camera_id="camera_1", track_id=None, bbox=None, frame_image=None, metadata=None, current_time=None):
17
+ """
18
+ Processes a threat event. Generates a validated AlertPayload if cooldown criteria is met.
19
+
20
+ Args:
21
+ threat_type (str): The type of threat (e.g. "fighting", "running")
22
+ confidence (float): The detection confidence (0.0 to 1.0)
23
+ camera_id (str): Camera identifier
24
+ track_id (int, optional): Persistent tracker ID of the subject
25
+ bbox (list, optional): Bounding box of the subject [x1, y1, x2, y2]
26
+ frame_image (str, optional): Base64 JPEG representation of the frame
27
+ metadata (dict, optional): Contextual statistics and metrics
28
+ current_time (float, optional): Timeline timestamp (e.g. video seconds). Defaults to time.time().
29
+
30
+ Returns:
31
+ AlertPayload or None: Returns the alert payload if successfully triggered, or None if deduplicated.
32
+ """
33
+ if current_time is None:
34
+ current_time = time.time()
35
+ history_key = (camera_id, threat_type.lower())
36
+
37
+ # Cooldown check
38
+ if history_key in self.alert_history:
39
+ elapsed = current_time - self.alert_history[history_key]
40
+ if elapsed < self.cooldown_seconds:
41
+ # Deduplicated (suppressed due to active cooldown)
42
+ return None
43
+
44
+ # Update last alert timestamp
45
+ self.alert_history[history_key] = current_time
46
+
47
+ # Resolve severity mapping
48
+ severity = ALERT_SEVERITY_MAPPING.get(threat_type.lower(), "MEDIUM")
49
+
50
+ # Construct and validate alert payload
51
+ alert = AlertPayload(
52
+ timestamp=datetime.now().isoformat(),
53
+ camera_id=camera_id,
54
+ threat_type=threat_type,
55
+ confidence=confidence,
56
+ severity=severity,
57
+ track_id=track_id,
58
+ bounding_box=[float(coord) for coord in bbox] if bbox is not None else None,
59
+ frame_image=frame_image,
60
+ metadata=metadata or {}
61
+ )
62
+
63
+ print(f"[AlertManager] [{severity}] Alert triggered: {threat_type} ({confidence*100:.1f}%) on {camera_id}!")
64
+ return alert
65
+
66
+ def reset_cooldowns(self):
67
+ """
68
+ Resets all active alert cooldown states.
69
+ """
70
+ self.alert_history.clear()
suspicious_behavior/alerts/alert_models.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field
2
+ from typing import List, Optional, Dict, Any
3
+
4
+ class AlertPayload(BaseModel):
5
+ """
6
+ Standardized payload format for system alerts.
7
+ Ready to be consumed by client webhooks or pushed via Firebase.
8
+ """
9
+ timestamp: str = Field(..., description="ISO 8601 formatted timestamp of the alert")
10
+ camera_id: str = Field(default="camera_1", description="Identifier of the source camera")
11
+ threat_type: str = Field(..., description="Detected behavior/threat type (e.g. fighting, running)")
12
+ confidence: float = Field(..., description="AI confidence score between 0.0 and 1.0")
13
+ severity: str = Field(..., description="Alert severity (CRITICAL, HIGH, MEDIUM, LOW)")
14
+ track_id: Optional[int] = Field(None, description="Persistent tracker ID of the subject")
15
+ bounding_box: Optional[List[float]] = Field(None, description="Bounding box of the subject [x1, y1, x2, y2]")
16
+ frame_image: Optional[str] = Field(None, description="Base64 encoded JPEG frame containing the detection")
17
+ metadata: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Additional debugging metrics or raw outputs")
suspicious_behavior/api/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # API submodule
suspicious_behavior/api/server.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import cv2
3
+ import numpy as np
4
+ import uuid
5
+ import shutil
6
+ from fastapi import FastAPI, File, UploadFile, Query, HTTPException
7
+ from fastapi.responses import FileResponse, JSONResponse
8
+ from fastapi.middleware.cors import CORSMiddleware
9
+ from pydantic import BaseModel
10
+ from typing import List, Dict, Any
11
+
12
+ from suspicious_behavior.config import API_HOST, API_PORT
13
+ from suspicious_behavior.pipeline.frame_analyzer import FrameAnalyzer
14
+ from suspicious_behavior.pipeline.video_processor import VideoProcessor
15
+
16
+ # Initialize FastAPI application
17
+ app = FastAPI(
18
+ title="SimShieldAI Suspicious Behavior API",
19
+ description="Production-grade API for detecting fighting, violence, and running/sprinting in CCTV surveillance.",
20
+ version="1.0.0"
21
+ )
22
+
23
+ # Add CORS Middleware
24
+ app.add_middleware(
25
+ CORSMiddleware,
26
+ allow_origins=["*"],
27
+ allow_credentials=True,
28
+ allow_methods=["*"],
29
+ allow_headers=["*"],
30
+ )
31
+
32
+ # Workspace directories for temporary assets
33
+ WORKSPACE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
34
+ TEMP_DIR = os.path.join(WORKSPACE_DIR, "temp")
35
+ os.makedirs(TEMP_DIR, exist_ok=True)
36
+
37
+ # Lazy loading of FrameAnalyzer to speed up initial server startup
38
+ analyzer = None
39
+
40
+ def get_analyzer():
41
+ global analyzer
42
+ if analyzer is None:
43
+ analyzer = FrameAnalyzer(camera_id="api_camera_1")
44
+ return analyzer
45
+
46
+
47
+ @app.get("/api/health")
48
+ async def health_check():
49
+ """
50
+ Checks system health and returns model status and execution device (CPU/GPU).
51
+ """
52
+ try:
53
+ # Initialize analyzer to verify models load successfully
54
+ inst = get_analyzer()
55
+ return {
56
+ "status": "healthy",
57
+ "device": str(inst.violence_engine.device),
58
+ "yolo_model": str(inst.yolo.model_name),
59
+ "violence_model": inst.violence_engine.labels,
60
+ "timestamp": str(np.datetime64('now'))
61
+ }
62
+ except Exception as e:
63
+ return JSONResponse(
64
+ status_code=500,
65
+ content={"status": "unhealthy", "error": str(e)}
66
+ )
67
+
68
+
69
+ @app.post("/api/detect")
70
+ async def detect_image(
71
+ file: UploadFile = File(..., description="JPEG/PNG image file containing the scene to analyze"),
72
+ return_image: bool = Query(True, description="If true, returns base64 annotated image in the JSON response")
73
+ ):
74
+ """
75
+ Analyzes a single image for suspicious behavior (such as running poses).
76
+ """
77
+ if not file.content_type.startswith("image/"):
78
+ raise HTTPException(status_code=400, detail="Uploaded file must be an image.")
79
+
80
+ try:
81
+ contents = await file.read()
82
+ nparr = np.frombuffer(contents, np.uint8)
83
+ img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
84
+
85
+ if img is None:
86
+ raise HTTPException(status_code=400, detail="Invalid image data.")
87
+
88
+ # Process image using our frame analyzer
89
+ # (VideoMAE is skipped for single frames if buffer is not full, which is normal)
90
+ inst = get_analyzer()
91
+ annotated_frame, alerts, metadata = inst.analyze(img, fps=10.0, output_base64=return_image)
92
+
93
+ response_data = {
94
+ "metadata": metadata,
95
+ "alerts_triggered": [alert.model_dump() for alert in alerts]
96
+ }
97
+
98
+ if return_image and len(alerts) > 0:
99
+ response_data["annotated_frame_base64"] = alerts[0].frame_image
100
+ elif return_image:
101
+ # If no alerts, manually encode the frame to base64
102
+ _, buffer = cv2.imencode('.jpg', annotated_frame)
103
+ import base64
104
+ response_data["annotated_frame_base64"] = base64.b64encode(buffer).decode('utf-8')
105
+
106
+ return response_data
107
+
108
+ except Exception as e:
109
+ raise HTTPException(status_code=500, detail=f"Image processing failed: {str(e)}")
110
+
111
+
112
+ @app.post("/api/analyze-video")
113
+ async def analyze_video(
114
+ file: UploadFile = File(..., description="MP4, AVI, or MOV video file to process")
115
+ ):
116
+ """
117
+ Processes an entire video clip. Annotates the video with tracking skeletons
118
+ and alert banners, and returns the fully processed video file for download.
119
+ """
120
+ # Check video extension
121
+ filename = file.filename
122
+ ext = os.path.splitext(filename)[1].lower()
123
+ if ext not in [".mp4", ".avi", ".mov", ".mkv"]:
124
+ raise HTTPException(status_code=400, detail="File must be a valid video format (mp4, avi, mov, mkv)")
125
+
126
+ # Define temporary file paths
127
+ unique_id = str(uuid.uuid4())
128
+ input_path = os.path.join(TEMP_DIR, f"input_{unique_id}{ext}")
129
+ output_path = os.path.join(TEMP_DIR, f"output_{unique_id}.mp4")
130
+
131
+ try:
132
+ # Save uploaded file to disk
133
+ with open(input_path, "wb") as buffer:
134
+ shutil.copyfileobj(file.file, buffer)
135
+
136
+ # Initialize processor and frame analyzer
137
+ processor = VideoProcessor(target_fps=10.0)
138
+ metadata = processor.get_metadata(input_path)
139
+
140
+ inst = get_analyzer()
141
+ # Reset state (so frame indices start clean for this video)
142
+ inst.frame_idx = 0
143
+ inst.violence_buffer.clear()
144
+ inst.alert_manager.reset_cooldowns()
145
+
146
+ processed_frames = []
147
+ all_metadata = []
148
+ all_alerts = []
149
+
150
+ print(f"[API] Processing video: {filename} ({metadata['frame_count']} frames at {metadata['fps']:.1f} FPS)...")
151
+
152
+ # Iterate through sampled frames
153
+ for f_idx, frame, timestamp in processor.extract_frames_generator(input_path):
154
+ annotated, alerts, frame_meta = inst.analyze(frame, fps=10.0, output_base64=False)
155
+ processed_frames.append(annotated)
156
+ all_metadata.append(frame_meta)
157
+ for alert in alerts:
158
+ all_alerts.append(alert.model_dump())
159
+
160
+ # Compile processed frames back to output video
161
+ # We process at 10.0 FPS, so output video should play back at 10.0 FPS
162
+ processor.write_frames_to_video(
163
+ processed_frames,
164
+ output_path,
165
+ fps=10.0,
166
+ frame_size=(metadata['width'], metadata['height'])
167
+ )
168
+
169
+ # Return the processed video file for immediate playback/download
170
+ # FastAPI will handle cleaning up files if we use background tasks, but for simple VM,
171
+ # we can return it and let the user delete it or keep a cache.
172
+ return FileResponse(
173
+ path=output_path,
174
+ filename=f"annotated_{filename}",
175
+ media_type="video/mp4"
176
+ )
177
+
178
+ except Exception as e:
179
+ # Cleanup input file if it was created
180
+ if os.path.exists(input_path):
181
+ os.remove(input_path)
182
+ raise HTTPException(status_code=500, detail=f"Video processing failed: {str(e)}")
183
+
184
+ finally:
185
+ # Clean up input file after processing is complete
186
+ if os.path.exists(input_path):
187
+ try:
188
+ os.remove(input_path)
189
+ except Exception:
190
+ pass
191
+
192
+
193
+ if __name__ == "__main__":
194
+ import uvicorn
195
+ print(f"[API] Starting FastAPI server on {API_HOST}:{API_PORT}...")
196
+ uvicorn.run("server:app", host=API_HOST, port=API_PORT, reload=True)
suspicious_behavior/config.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ # --- System Environment ---
4
+ ENV = os.getenv("ENV", "development")
5
+
6
+ # --- Model Configurations ---
7
+ # Standard YOLOv8n for fast person detection (class 0 is person)
8
+ YOLO_MODEL_PATH = os.getenv("YOLO_MODEL_PATH", "yolov8n.pt")
9
+
10
+ # VideoMAE Violence Detection model from Hugging Face
11
+ VIOLENCE_MODEL_NAME = os.getenv("VIOLENCE_MODEL_NAME", "Nikeytas/videomae-crime-detector-ultra-v1")
12
+ VIOLENCE_PROCESSOR_NAME = os.getenv("VIOLENCE_PROCESSOR_NAME", "MCG-NJU/videomae-base")
13
+
14
+ # --- Violence Detection Parameters ---
15
+ VIOLENCE_CONFIDENCE_THRESHOLD = 0.70 # Alert if confidence above this
16
+ VIOLENCE_FRAME_COUNT = 16 # VideoMAE expects exactly 16 frames
17
+
18
+ # --- Running/Sprinting Parameters (MediaPipe Rules) ---
19
+ RUNNING_CONFIDENCE_THRESHOLD = 0.70
20
+ MIN_RUNNING_FRAMES = 4 # Temporal persistence (must persist for 4 frames)
21
+
22
+ # Weighted scoring rule parameters
23
+ RULE_STRIDE_THRESHOLD = 45.0 # degrees
24
+ RULE_STRIDE_WEIGHT = 0.30
25
+
26
+ RULE_LEAN_THRESHOLD = 18.0 # degrees
27
+ RULE_LEAN_WEIGHT = 0.20
28
+
29
+ RULE_DISPLACEMENT_THRESHOLD = 5.0 # normalized displacement (speed)
30
+ RULE_DISPLACEMENT_WEIGHT = 0.30
31
+
32
+ RULE_KNEE_DRIVE_THRESHOLD = 0.30 # ratio to thigh/leg length
33
+ RULE_KNEE_DRIVE_WEIGHT = 0.20
34
+
35
+ # Anti-Fight Filter: prevents fighting from being classified as running
36
+ DIRECTION_CONSISTENCY_THRESHOLD = 0.5 # At least 50% of frames must have consistent direction
37
+ MIN_DISPLACEMENT_FOR_RUNNING = 1.5 # Minimum net displacement (in torso heights) over tracking window
38
+
39
+ # Minimum person crop size (pixels) — skip pose analysis on tiny distant people
40
+ MIN_PERSON_CROP_SIZE = 80 # Both width and height must exceed this
41
+
42
+ # --- Person Tracker Parameters (SORT) ---
43
+ TRACKER_MAX_AGE = 30 # Maximum frames to keep lost track alive (increased for fight occlusion)
44
+ TRACKER_MIN_HITS = 3 # Minimum hits before track is confirmed
45
+ TRACKER_IOU_THRESHOLD = 0.2 # Lowered to improve re-association during close contact
46
+
47
+ # --- Violence State Smoothing ---
48
+ VIOLENCE_DECAY_SECONDS = 5.0 # Keep violence active for 5s after last positive detection
49
+ VIOLENCE_EMA_ALPHA = 0.3 # Exponential moving average weight (lower = smoother, 0.3 is conservative)
50
+
51
+ # --- Violence Escalation ---
52
+ VIOLENCE_ESCALATION_SECONDS = 10.0 # Sustained violence duration to trigger escalation
53
+ VIOLENCE_ESCALATION_THRESHOLD = 0.95 # Confidence must stay above this for escalation
54
+
55
+ # --- Alert Manager Parameters ---
56
+ ALERT_COOLDOWN_SECONDS = 60.0 # Cooldown per camera per threat type
57
+ ALERT_SEVERITY_MAPPING = {
58
+ "fighting": "CRITICAL",
59
+ "violence": "CRITICAL",
60
+ "violence_escalated": "CRITICAL",
61
+ "running": "HIGH",
62
+ "sprinting": "HIGH",
63
+ "suspicious": "MEDIUM"
64
+ }
65
+
66
+ # --- FastAPI API Configurations ---
67
+ API_HOST = "0.0.0.0"
68
+ API_PORT = 8000
suspicious_behavior/engines/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Engines submodule
suspicious_behavior/engines/running_engine.py ADDED
@@ -0,0 +1,309 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ import numpy as np
3
+ import mediapipe as mp
4
+ from suspicious_behavior.config import (
5
+ RULE_STRIDE_THRESHOLD, RULE_STRIDE_WEIGHT,
6
+ RULE_LEAN_THRESHOLD, RULE_LEAN_WEIGHT,
7
+ RULE_DISPLACEMENT_THRESHOLD, RULE_DISPLACEMENT_WEIGHT,
8
+ RULE_KNEE_DRIVE_THRESHOLD, RULE_KNEE_DRIVE_WEIGHT,
9
+ RUNNING_CONFIDENCE_THRESHOLD, MIN_RUNNING_FRAMES,
10
+ DIRECTION_CONSISTENCY_THRESHOLD, MIN_DISPLACEMENT_FOR_RUNNING
11
+ )
12
+
13
+ class RunningEngine:
14
+ """
15
+ MediaPipe Pose-based running and sprinting detection engine.
16
+ Analyzes body skeleton landmarks and kinematics to calculate a running confidence score.
17
+ """
18
+ def __init__(self):
19
+ # Initialize MediaPipe Pose
20
+ self.mp_pose = mp.solutions.pose
21
+ self.pose = self.mp_pose.Pose(
22
+ static_image_mode=False,
23
+ model_complexity=1, # Balanced for speed/accuracy on CPU
24
+ enable_segmentation=False,
25
+ min_detection_confidence=0.5,
26
+ min_tracking_confidence=0.5
27
+ )
28
+
29
+ # Track history for displacement speed calculation
30
+ # Format: {track_id: [(frame_idx, hip_center_x, hip_center_y, torso_height), ...]}
31
+ self.track_history = {}
32
+ self.max_history_len = 10
33
+
34
+ # Direction history for consistency check (filters out erratic fight movement)
35
+ # Format: {track_id: [direction_angle_radians, ...]}
36
+ self.direction_history = {}
37
+ self.max_direction_history = 6
38
+
39
+ # Consecutive running frame count per track (temporal persistence filter)
40
+ # Format: {track_id: int}
41
+ self.running_streak = {}
42
+
43
+ def clean_history(self, active_track_ids):
44
+ """
45
+ Cleans up tracking history for IDs that are no longer active to prevent memory leaks.
46
+ """
47
+ for store in [self.track_history, self.direction_history, self.running_streak]:
48
+ inactive_ids = [tid for tid in store if tid not in active_track_ids]
49
+ for tid in inactive_ids:
50
+ del store[tid]
51
+
52
+ def _calculate_angle_2d(self, a, b, c):
53
+ """
54
+ Calculate angle ABC in degrees (vertex is b).
55
+ a, b, c are tuples or numpy arrays of (x, y).
56
+ """
57
+ ba = np.array(a) - np.array(b)
58
+ bc = np.array(c) - np.array(b)
59
+
60
+ cosine_angle = np.dot(ba, bc) / (np.linalg.norm(ba) * np.linalg.norm(bc) + 1e-6)
61
+ cosine_angle = np.clip(cosine_angle, -1.0, 1.0)
62
+ angle = np.arccos(cosine_angle)
63
+
64
+ return np.degrees(angle)
65
+
66
+ def analyze_pose(self, person_crop, track_id, frame_idx, fps=10.0):
67
+ """
68
+ Processes a person crop to extract skeleton landmarks and calculate running confidence.
69
+
70
+ Args:
71
+ person_crop (numpy.ndarray): Bounding box crop of the person from the frame
72
+ track_id (int): Persistent ID of the person from the tracker
73
+ frame_idx (int): Current frame index in the video
74
+ fps (float): Video processing framerate
75
+
76
+ Returns:
77
+ dict: Detection results containing keypoints, rule scores, and final confidence.
78
+ """
79
+ if person_crop is None or person_crop.size == 0:
80
+ return self._empty_result()
81
+
82
+ # Convert BGR to RGB for MediaPipe
83
+ rgb_crop = person_crop[:, :, ::-1]
84
+ results = self.pose.process(rgb_crop)
85
+
86
+ if not results.pose_landmarks:
87
+ return self._empty_result()
88
+
89
+ landmarks = results.pose_landmarks.landmark
90
+ h_crop, w_crop, _ = person_crop.shape
91
+
92
+ # Helper to convert landmark to pixel coordinates relative to crop
93
+ def lm_point(lm):
94
+ return np.array([lm.x * w_crop, lm.y * h_crop])
95
+
96
+ # Extract landmarks needed for rules
97
+ try:
98
+ # Hips
99
+ left_hip = lm_point(landmarks[self.mp_pose.PoseLandmark.LEFT_HIP])
100
+ right_hip = lm_point(landmarks[self.mp_pose.PoseLandmark.RIGHT_HIP])
101
+ hip_center = (left_hip + right_hip) / 2.0
102
+
103
+ # Shoulders
104
+ left_shoulder = lm_point(landmarks[self.mp_pose.PoseLandmark.LEFT_SHOULDER])
105
+ right_shoulder = lm_point(landmarks[self.mp_pose.PoseLandmark.RIGHT_SHOULDER])
106
+ shoulder_center = (left_shoulder + right_shoulder) / 2.0
107
+
108
+ # Knees
109
+ left_knee = lm_point(landmarks[self.mp_pose.PoseLandmark.LEFT_KNEE])
110
+ right_knee = lm_point(landmarks[self.mp_pose.PoseLandmark.RIGHT_KNEE])
111
+
112
+ # Ankles
113
+ left_ankle = lm_point(landmarks[self.mp_pose.PoseLandmark.LEFT_ANKLE])
114
+ right_ankle = lm_point(landmarks[self.mp_pose.PoseLandmark.RIGHT_ANKLE])
115
+
116
+ # Torso height acts as our normalizer for displacement and knee height
117
+ torso_vector = shoulder_center - hip_center
118
+ torso_height = np.linalg.norm(torso_vector) + 1e-6
119
+
120
+ except (IndexError, ValueError):
121
+ return self._empty_result()
122
+
123
+ # --- RULE 1: Stride Angle ---
124
+ # Angle between left ankle -> hip center -> right ankle
125
+ stride_angle = self._calculate_angle_2d(left_ankle, hip_center, right_ankle)
126
+ # Normalize stride score: 0 to 1 based on threshold
127
+ stride_score = min(stride_angle / RULE_STRIDE_THRESHOLD, 1.5)
128
+ # We cap the score at 1.0 but allow a small boost for extreme strides
129
+ stride_score = min(stride_score, 1.0)
130
+
131
+ # --- RULE 2: Body Lean ---
132
+ # Torso vector angle relative to the vertical axis (0, -1) pointing up
133
+ # In screen space, y decreases upwards.
134
+ torso_dy = hip_center[1] - shoulder_center[1] # positive if shoulder is above hip
135
+ torso_dx = shoulder_center[0] - hip_center[0]
136
+
137
+ # Calculate angle of torso from vertical
138
+ lean_angle_rad = math.atan2(abs(torso_dx), torso_dy)
139
+ lean_angle = math.degrees(lean_angle_rad)
140
+
141
+ lean_score = min(lean_angle / RULE_LEAN_THRESHOLD, 1.0)
142
+
143
+ # --- RULE 3: Knee Drive ---
144
+ # Vertical distance from knee to hip normalized by torso height
145
+ # Higher knee drive = running.
146
+ left_knee_lift = (hip_center[1] - left_knee[1]) / torso_height
147
+ right_knee_lift = (hip_center[1] - right_knee[1]) / torso_height
148
+ max_knee_lift = max(left_knee_lift, right_knee_lift)
149
+
150
+ knee_drive_score = min(max(max_knee_lift, 0.0) / RULE_KNEE_DRIVE_THRESHOLD, 1.0)
151
+
152
+ # --- RULE 4: Displacement Speed + Direction Consistency ---
153
+ speed_score = 0.0
154
+ normalized_speed = 0.0
155
+ direction_consistency = 0.0
156
+ net_displacement = 0.0
157
+
158
+ # Update history for track ID
159
+ if track_id is not None:
160
+ if track_id not in self.track_history:
161
+ self.track_history[track_id] = []
162
+ if track_id not in self.direction_history:
163
+ self.direction_history[track_id] = []
164
+ if track_id not in self.running_streak:
165
+ self.running_streak[track_id] = 0
166
+
167
+ self.track_history[track_id].append((frame_idx, hip_center[0], hip_center[1], torso_height))
168
+
169
+ # Keep history within limit
170
+ if len(self.track_history[track_id]) > self.max_history_len:
171
+ self.track_history[track_id].pop(0)
172
+
173
+ # Compute speed and direction if we have history
174
+ history = self.track_history[track_id]
175
+ if len(history) >= 2:
176
+ # Frame-to-frame displacement for direction tracking
177
+ prev_frame, prev_x, prev_y, prev_th = history[-2]
178
+ dx_frame = hip_center[0] - prev_x
179
+ dy_frame = hip_center[1] - prev_y
180
+ frame_dist = math.sqrt(dx_frame**2 + dy_frame**2)
181
+
182
+ # Track direction angle if person actually moved
183
+ if frame_dist > 2.0: # Minimum pixel movement to register direction
184
+ direction_angle = math.atan2(dy_frame, dx_frame)
185
+ self.direction_history[track_id].append(direction_angle)
186
+ if len(self.direction_history[track_id]) > self.max_direction_history:
187
+ self.direction_history[track_id].pop(0)
188
+
189
+ # Compare current frame with the oldest frame in history for speed
190
+ old_frame, old_x, old_y, old_th = history[0]
191
+ frame_diff = frame_idx - old_frame
192
+
193
+ if frame_diff > 0:
194
+ time_diff = frame_diff / fps
195
+ # Compute pixel displacement
196
+ dx = hip_center[0] - old_x
197
+ dy = hip_center[1] - old_y
198
+ pixel_dist = math.sqrt(dx**2 + dy**2)
199
+
200
+ # Normalize displacement by average torso height of the person
201
+ avg_torso_height = (torso_height + old_th) / 2.0
202
+ normalized_dist = pixel_dist / avg_torso_height
203
+ net_displacement = normalized_dist
204
+
205
+ # Normalized Speed is: torso heights moved per second
206
+ normalized_speed = normalized_dist / time_diff
207
+ speed_score = min(normalized_speed / RULE_DISPLACEMENT_THRESHOLD, 1.0)
208
+
209
+ # --- Direction Consistency Check ---
210
+ # Running has consistent directional movement.
211
+ # Fighting has erratic, back-and-forth movement with low net displacement.
212
+ dir_hist = self.direction_history[track_id]
213
+ if len(dir_hist) >= 3:
214
+ # Compute angular differences between consecutive directions
215
+ angle_diffs = []
216
+ for i in range(1, len(dir_hist)):
217
+ diff = abs(dir_hist[i] - dir_hist[i-1])
218
+ # Normalize to [0, pi]
219
+ if diff > math.pi:
220
+ diff = 2 * math.pi - diff
221
+ angle_diffs.append(diff)
222
+
223
+ # Consistency = fraction of frames where direction change is small (<45 degrees)
224
+ consistent_count = sum(1 for d in angle_diffs if d < math.pi / 4)
225
+ direction_consistency = consistent_count / len(angle_diffs)
226
+ else:
227
+ # Not enough direction history — default to low consistency (safer)
228
+ direction_consistency = 0.0
229
+
230
+ # --- Weighted Running Score ---
231
+ running_confidence = (
232
+ (stride_score * RULE_STRIDE_WEIGHT) +
233
+ (lean_score * RULE_LEAN_WEIGHT) +
234
+ (knee_drive_score * RULE_KNEE_DRIVE_WEIGHT) +
235
+ (speed_score * RULE_DISPLACEMENT_WEIGHT)
236
+ )
237
+
238
+ # --- Anti-Fight Filter ---
239
+ # Suppress running classification if movement is erratic (fighting pattern).
240
+ # Real running requires: consistent direction + meaningful net displacement.
241
+ is_running_raw = running_confidence >= RUNNING_CONFIDENCE_THRESHOLD
242
+ is_running = False
243
+
244
+ if is_running_raw:
245
+ has_consistent_direction = direction_consistency >= DIRECTION_CONSISTENCY_THRESHOLD
246
+ has_net_displacement = net_displacement >= MIN_DISPLACEMENT_FOR_RUNNING
247
+
248
+ if has_consistent_direction and has_net_displacement:
249
+ # Temporal persistence: must be running for MIN_RUNNING_FRAMES consecutive frames
250
+ self.running_streak[track_id] = self.running_streak.get(track_id, 0) + 1
251
+ if self.running_streak[track_id] >= MIN_RUNNING_FRAMES:
252
+ is_running = True
253
+ else:
254
+ # Erratic movement detected — likely fighting, not running
255
+ self.running_streak[track_id] = 0
256
+ else:
257
+ self.running_streak[track_id] = 0
258
+
259
+ # Structure normalized landmarks for overlays (relative to crop)
260
+ keypoints = {}
261
+ for lm_enum in self.mp_pose.PoseLandmark:
262
+ lm = landmarks[lm_enum.value]
263
+ keypoints[lm_enum.name] = {
264
+ "x": lm.x, "y": lm.y, "z": lm.z, "visibility": lm.visibility
265
+ }
266
+
267
+ return {
268
+ "is_detected": is_running,
269
+ "confidence": float(running_confidence) if is_running else float(running_confidence * 0.5),
270
+ "class_label": "running" if is_running else "walking/standing",
271
+ "metrics": {
272
+ "stride_angle": float(stride_angle),
273
+ "lean_angle": float(lean_angle),
274
+ "knee_drive_ratio": float(max_knee_lift),
275
+ "normalized_speed": float(normalized_speed),
276
+ "direction_consistency": float(direction_consistency),
277
+ "net_displacement": float(net_displacement)
278
+ },
279
+ "rule_scores": {
280
+ "stride": float(stride_score),
281
+ "lean": float(lean_score),
282
+ "knee_drive": float(knee_drive_score),
283
+ "speed": float(speed_score)
284
+ },
285
+ "pose_landmarks": results.pose_landmarks
286
+ }
287
+
288
+ def _empty_result(self):
289
+ """
290
+ Returns a default empty result structure when landmarks cannot be detected.
291
+ """
292
+ return {
293
+ "is_detected": False,
294
+ "confidence": 0.0,
295
+ "class_label": "unknown",
296
+ "metrics": {
297
+ "stride_angle": 0.0,
298
+ "lean_angle": 0.0,
299
+ "knee_drive_ratio": 0.0,
300
+ "normalized_speed": 0.0
301
+ },
302
+ "rule_scores": {
303
+ "stride": 0.0,
304
+ "lean": 0.0,
305
+ "knee_drive": 0.0,
306
+ "speed": 0.0
307
+ },
308
+ "pose_landmarks": None
309
+ }
suspicious_behavior/engines/violence_engine.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import numpy as np
3
+ from transformers import VideoMAEForVideoClassification, VideoMAEImageProcessor
4
+ from suspicious_behavior.config import VIOLENCE_MODEL_NAME, VIOLENCE_PROCESSOR_NAME, VIOLENCE_CONFIDENCE_THRESHOLD
5
+
6
+ class ViolenceEngine:
7
+ """
8
+ VideoMAE-based violence detection engine.
9
+ Analyzes temporal cues across a 16-frame clip to detect fighting/assault.
10
+ """
11
+ def __init__(self):
12
+ self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
13
+ print(f"[ViolenceEngine] Loading VideoMAE model '{VIOLENCE_MODEL_NAME}' on {self.device}...")
14
+
15
+ # Load pre-trained models
16
+ self.model = VideoMAEForVideoClassification.from_pretrained(VIOLENCE_MODEL_NAME).to(self.device)
17
+ self.processor = VideoMAEImageProcessor.from_pretrained(VIOLENCE_PROCESSOR_NAME)
18
+ self.model.eval()
19
+
20
+ # Load labels from model configuration
21
+ self.labels = self.model.config.id2label
22
+ print(f"[ViolenceEngine] Model loaded successfully. Labels: {self.labels}")
23
+
24
+ def predict(self, frames):
25
+ """
26
+ Predict behavior class from a list of 16 frames.
27
+
28
+ Args:
29
+ frames (list): List of 16 numpy arrays (frames in BGR color space)
30
+
31
+ Returns:
32
+ dict: Classification results containing detection status, confidence, and score breakdown.
33
+ """
34
+ if len(frames) != 16:
35
+ raise ValueError(f"ViolenceEngine expects exactly 16 frames, got {len(frames)}")
36
+
37
+ # Convert BGR (OpenCV default) to RGB and make a contiguous copy to avoid negative strides
38
+ rgb_frames = []
39
+ for frame in frames:
40
+ if isinstance(frame, np.ndarray):
41
+ rgb_frames.append(frame[:, :, ::-1].copy())
42
+ else:
43
+ rgb_frames.append(frame)
44
+
45
+ # Preprocess and prepare tensor batch
46
+ inputs = self.processor(rgb_frames, return_tensors="pt")
47
+ inputs = {k: v.to(self.device) for k, v in inputs.items()}
48
+
49
+ with torch.no_grad():
50
+ outputs = self.model(**inputs)
51
+ logits = outputs.logits
52
+ probs = torch.softmax(logits, dim=-1)[0]
53
+
54
+ # Extract raw scores
55
+ raw_results = {}
56
+ for idx, prob in enumerate(probs):
57
+ label = self.labels[idx].lower()
58
+ raw_results[label] = float(prob.cpu().numpy())
59
+
60
+ # Map binary classifications (label_0 = normal, label_1 = violent)
61
+ results = {
62
+ "normal": raw_results.get("label_0", raw_results.get("0", 0.0)),
63
+ "violence": raw_results.get("label_1", raw_results.get("1", 0.0))
64
+ }
65
+
66
+ # Define targets that warrant immediate alerts
67
+ max_violence_prob = results.get("violence", 0.0)
68
+ detected_class = "violence"
69
+ is_detected = max_violence_prob >= VIOLENCE_CONFIDENCE_THRESHOLD
70
+
71
+ return {
72
+ "is_detected": is_detected,
73
+ "confidence": max_violence_prob if is_detected else results.get("normal", 1.0 - max_violence_prob),
74
+ "class_label": detected_class if is_detected else "normal",
75
+ "all_scores": results
76
+ }
suspicious_behavior/pipeline/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Pipeline submodule
suspicious_behavior/pipeline/annotator.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cv2
2
+ import numpy as np
3
+ import mediapipe as mp
4
+
5
+ class Annotator:
6
+ """
7
+ Renders professional-grade computer vision annotations on frames.
8
+ Draws custom skeletons, sleek bounding boxes, and security alert banners.
9
+ """
10
+ def __init__(self):
11
+ self.mp_pose = mp.solutions.pose
12
+
13
+ # Premium color palette (BGR format)
14
+ self.COLOR_LEFT = (230, 180, 50) # Sleek teal/cyan
15
+ self.COLOR_RIGHT = (80, 80, 250) # Coral/salmon pink
16
+ self.COLOR_MIDDLE = (50, 220, 240) # Warm yellow
17
+ self.COLOR_TEXT = (255, 255, 255) # White
18
+ self.COLOR_NORMAL_BOX = (200, 200, 200) # Soft gray
19
+ self.COLOR_ALERT_FIGHT = (45, 45, 230) # Crimson red
20
+ self.COLOR_ALERT_RUN = (0, 140, 255) # Amber orange
21
+
22
+ def draw_skeleton(self, frame, landmarks, bbox):
23
+ """
24
+ Draws a customized, color-coded skeleton within the person's bounding box.
25
+ """
26
+ if not landmarks:
27
+ return frame
28
+
29
+ h_img, w_img, _ = frame.shape
30
+ x1, y1, x2, y2 = bbox
31
+ w_box = x2 - x1
32
+ h_box = y2 - y1
33
+
34
+ # Extract landmarks and project from crop-space to full image coordinates
35
+ pts = {}
36
+ for idx, lm in enumerate(landmarks.landmark):
37
+ # Coordinates are normalized relative to the crop dimensions
38
+ px = int(x1 + lm.x * w_box)
39
+ py = int(y1 + lm.y * h_box)
40
+ # Clip coordinates to image borders
41
+ px = max(0, min(px, w_img - 1))
42
+ py = max(0, min(py, h_img - 1))
43
+ pts[idx] = (px, py, lm.visibility)
44
+
45
+ # Define connection segments with color codes
46
+ connections = [
47
+ # Torso (Middle)
48
+ (self.mp_pose.PoseLandmark.LEFT_SHOULDER, self.mp_pose.PoseLandmark.RIGHT_SHOULDER, self.COLOR_MIDDLE),
49
+ (self.mp_pose.PoseLandmark.LEFT_HIP, self.mp_pose.PoseLandmark.RIGHT_HIP, self.COLOR_MIDDLE),
50
+ (self.mp_pose.PoseLandmark.LEFT_SHOULDER, self.mp_pose.PoseLandmark.LEFT_HIP, self.COLOR_MIDDLE),
51
+ (self.mp_pose.PoseLandmark.RIGHT_SHOULDER, self.mp_pose.PoseLandmark.RIGHT_HIP, self.COLOR_MIDDLE),
52
+
53
+ # Left Arm (Teal)
54
+ (self.mp_pose.PoseLandmark.LEFT_SHOULDER, self.mp_pose.PoseLandmark.LEFT_ELBOW, self.COLOR_LEFT),
55
+ (self.mp_pose.PoseLandmark.LEFT_ELBOW, self.mp_pose.PoseLandmark.LEFT_WRIST, self.COLOR_LEFT),
56
+
57
+ # Right Arm (Coral)
58
+ (self.mp_pose.PoseLandmark.RIGHT_SHOULDER, self.mp_pose.PoseLandmark.RIGHT_ELBOW, self.COLOR_RIGHT),
59
+ (self.mp_pose.PoseLandmark.RIGHT_ELBOW, self.mp_pose.PoseLandmark.RIGHT_WRIST, self.COLOR_RIGHT),
60
+
61
+ # Left Leg (Teal)
62
+ (self.mp_pose.PoseLandmark.LEFT_HIP, self.mp_pose.PoseLandmark.LEFT_KNEE, self.COLOR_LEFT),
63
+ (self.mp_pose.PoseLandmark.LEFT_KNEE, self.mp_pose.PoseLandmark.LEFT_ANKLE, self.COLOR_LEFT),
64
+
65
+ # Right Leg (Coral)
66
+ (self.mp_pose.PoseLandmark.RIGHT_HIP, self.mp_pose.PoseLandmark.RIGHT_KNEE, self.COLOR_RIGHT),
67
+ (self.mp_pose.PoseLandmark.RIGHT_KNEE, self.mp_pose.PoseLandmark.RIGHT_ANKLE, self.COLOR_RIGHT),
68
+ ]
69
+
70
+ # Draw connection lines
71
+ for start_enum, end_enum, color in connections:
72
+ start_pt = pts[start_enum.value]
73
+ end_pt = pts[end_enum.value]
74
+
75
+ # Only draw if landmarks are visible enough
76
+ if start_pt[2] > 0.5 and end_pt[2] > 0.5:
77
+ cv2.line(frame, start_pt[:2], end_pt[:2], color, 2, cv2.LINE_AA)
78
+
79
+ # Draw joint keypoints
80
+ for idx, pt in pts.items():
81
+ if pt[2] > 0.5:
82
+ # Exclude face features (0-10) for cleaner physical motion display
83
+ if idx > 10:
84
+ color = self.COLOR_LEFT if idx % 2 == 1 else self.COLOR_RIGHT
85
+ cv2.circle(frame, pt[:2], 3, color, -1, cv2.LINE_AA)
86
+
87
+ return frame
88
+
89
+ def draw_predictions(self, frame, tracked_objects, behaviors):
90
+ """
91
+ Draws bounding boxes, labels, and skeletons for all tracked persons.
92
+
93
+ Args:
94
+ frame (numpy.ndarray): BGR image frame
95
+ tracked_objects (list): List of tuples (bbox, track_id)
96
+ behaviors (dict): Mapping {track_id: behavior_dict}
97
+
98
+ Returns:
99
+ numpy.ndarray: Annotated frame
100
+ """
101
+ for bbox, track_id in tracked_objects:
102
+ x1, y1, x2, y2 = [int(c) for c in bbox]
103
+
104
+ # Fetch behavior information for this ID
105
+ bh = behaviors.get(track_id, {"class_label": "normal", "confidence": 0.0, "pose_landmarks": None})
106
+ label = bh["class_label"]
107
+ conf = bh["confidence"]
108
+ landmarks = bh["pose_landmarks"]
109
+
110
+ # Determine color based on severity of state
111
+ if label in ["fighting", "violence", "assault"]:
112
+ color = self.COLOR_ALERT_FIGHT
113
+ elif label == "running":
114
+ color = self.COLOR_ALERT_RUN
115
+ else:
116
+ color = self.COLOR_NORMAL_BOX
117
+
118
+ # Draw sleek bounding box
119
+ cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2, cv2.LINE_AA)
120
+
121
+ # Draw skeleton overlay
122
+ if landmarks is not None:
123
+ self.draw_skeleton(frame, landmarks, (x1, y1, x2, y2))
124
+
125
+ # Draw label plaque above bounding box
126
+ label_text = f"ID {track_id}: {label.upper()} ({conf*100:.0f}%)"
127
+ (text_w, text_h), baseline = cv2.getTextSize(label_text, cv2.FONT_HERSHEY_SIMPLEX, 0.5, 1)
128
+
129
+ # Draw plaque background
130
+ cv2.rectangle(
131
+ frame,
132
+ (x1, y1 - text_h - 10),
133
+ (x1 + text_w + 10, y1),
134
+ color,
135
+ -1
136
+ )
137
+ # Write text on plaque
138
+ cv2.putText(
139
+ frame,
140
+ label_text,
141
+ (x1 + 5, y1 - 5),
142
+ cv2.FONT_HERSHEY_SIMPLEX,
143
+ 0.5,
144
+ self.COLOR_TEXT,
145
+ 1,
146
+ cv2.LINE_AA
147
+ )
148
+
149
+ return frame
150
+
151
+ def draw_alert_banners(self, frame, active_alerts):
152
+ """
153
+ Renders eye-catching alert banners at the top of the screen for active threats.
154
+
155
+ Args:
156
+ frame (numpy.ndarray): Frame to annotate
157
+ active_alerts (list): List of strings indicating active behaviors (e.g. ["FIGHTING", "RUNNING"])
158
+
159
+ Returns:
160
+ numpy.ndarray: Frame with alert banners
161
+ """
162
+ if not active_alerts:
163
+ return frame
164
+
165
+ h_img, w_img, _ = frame.shape
166
+ banner_h = int(h_img * 0.08) # Banner takes 8% of vertical space
167
+
168
+ # Sort so CRITICAL alerts are processed first
169
+ sorted_alerts = sorted(active_alerts, key=lambda a: 0 if a in ["FIGHTING", "VIOLENCE", "ASSAULT"] else 1)
170
+ primary_alert = sorted_alerts[0]
171
+
172
+ # Set banner color and text
173
+ if primary_alert in ["FIGHTING", "VIOLENCE", "ASSAULT"]:
174
+ color = self.COLOR_ALERT_FIGHT
175
+ banner_text = f"ALERT: ACTIVE {primary_alert.upper()} DETECTED"
176
+ else:
177
+ color = self.COLOR_ALERT_RUN
178
+ banner_text = f"ALERT: SUSPICIOUS {primary_alert.upper()} MOTION"
179
+
180
+ # Create semi-transparent overlay banner
181
+ overlay = frame.copy()
182
+ cv2.rectangle(overlay, (0, 0), (w_img, banner_h), color, -1)
183
+ # Apply overlay with alpha blend (opacity 85%)
184
+ cv2.addWeighted(overlay, 0.85, frame, 0.15, 0, frame)
185
+
186
+ # Draw a highlighted bottom stripe on the banner
187
+ cv2.line(frame, (0, banner_h), (w_img, banner_h), (255, 255, 255), 2, cv2.LINE_AA)
188
+
189
+ # Render alert text centered
190
+ font_scale = max(0.6, banner_h / 60.0)
191
+ thickness = max(2, int(banner_h / 25))
192
+ (text_w, text_h), _ = cv2.getTextSize(banner_text, cv2.FONT_HERSHEY_SIMPLEX, font_scale, thickness)
193
+
194
+ text_x = int((w_img - text_w) / 2)
195
+ text_y = int((banner_h + text_h) / 2)
196
+
197
+ cv2.putText(
198
+ frame,
199
+ banner_text,
200
+ (text_x, text_y),
201
+ cv2.FONT_HERSHEY_SIMPLEX,
202
+ font_scale,
203
+ self.COLOR_TEXT,
204
+ thickness,
205
+ cv2.LINE_AA
206
+ )
207
+
208
+ return frame
209
+
210
+ def draw_timestamp_overlay(self, frame, timestamp_str):
211
+ """
212
+ Draws system details and timestamp in the bottom-right corner.
213
+ """
214
+ h_img, w_img, _ = frame.shape
215
+ info_text = f"SimShieldAI Secure | {timestamp_str}"
216
+
217
+ (text_w, text_h), _ = cv2.getTextSize(info_text, cv2.FONT_HERSHEY_SIMPLEX, 0.45, 1)
218
+
219
+ # Semi-transparent backing rectangle for timestamp
220
+ cv2.rectangle(
221
+ frame,
222
+ (w_img - text_w - 20, h_img - text_h - 20),
223
+ (w_img - 5, h_img - 5),
224
+ (0, 0, 0),
225
+ -1
226
+ )
227
+
228
+ cv2.putText(
229
+ frame,
230
+ info_text,
231
+ (w_img - text_w - 12, h_img - 10),
232
+ cv2.FONT_HERSHEY_SIMPLEX,
233
+ 0.45,
234
+ (180, 180, 180),
235
+ 1,
236
+ cv2.LINE_AA
237
+ )
238
+
239
+ return frame
suspicious_behavior/pipeline/frame_analyzer.py ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cv2
2
+ import numpy as np
3
+ import time
4
+ import base64
5
+ from datetime import datetime
6
+ from ultralytics import YOLO
7
+
8
+ from suspicious_behavior.config import (
9
+ YOLO_MODEL_PATH, VIOLENCE_FRAME_COUNT, MIN_PERSON_CROP_SIZE,
10
+ VIOLENCE_DECAY_SECONDS, VIOLENCE_EMA_ALPHA,
11
+ VIOLENCE_ESCALATION_SECONDS, VIOLENCE_ESCALATION_THRESHOLD
12
+ )
13
+ from suspicious_behavior.engines.violence_engine import ViolenceEngine
14
+ from suspicious_behavior.engines.running_engine import RunningEngine
15
+ from suspicious_behavior.tracking.sort_tracker import SortTracker
16
+ from suspicious_behavior.pipeline.annotator import Annotator
17
+ from suspicious_behavior.alerts.alert_manager import AlertManager
18
+
19
+ class FrameAnalyzer:
20
+ """
21
+ Main pipeline orchestrator.
22
+ Combines person detection, tracking, running pose analysis, and video clip violence classification.
23
+ """
24
+ def __init__(self, camera_id="camera_1", violence_stride=8):
25
+ self.camera_id = camera_id
26
+ self.violence_stride = violence_stride
27
+ self.frame_idx = 0
28
+
29
+ # Initialize sub-modules
30
+ print(f"[FrameAnalyzer] Initializing person detection model: {YOLO_MODEL_PATH}...")
31
+ self.yolo = YOLO(YOLO_MODEL_PATH)
32
+
33
+ self.tracker = SortTracker()
34
+ self.running_engine = RunningEngine()
35
+ self.violence_engine = ViolenceEngine()
36
+ self.annotator = Annotator()
37
+ self.alert_manager = AlertManager()
38
+
39
+ # Sliding buffer for violence detection (stores RGB frames)
40
+ self.violence_buffer = []
41
+ # Stores the actual coordinates, labels, and landmarks of tracked people
42
+ # Format: {track_id: behavior_result_dict}
43
+ self.current_behaviors = {}
44
+
45
+ # --- Violence State Smoothing ---
46
+ self._violence_active = False
47
+ self._violence_confidence = 0.0
48
+ self._violence_last_detected_time = 0.0 # Timestamp of last positive violence detection
49
+ self._violence_ema_score = 0.0 # Exponentially smoothed violence confidence
50
+
51
+ # --- Violence Escalation ---
52
+ self._violence_sustained_start = None # Timestamp when sustained violence began
53
+ self._violence_escalation_fired = False # Whether escalation alert was already sent
54
+
55
+ def analyze(self, frame, fps=10.0, output_base64=False):
56
+ """
57
+ Processes a single video frame.
58
+
59
+ Args:
60
+ frame (numpy.ndarray): OpenCV BGR image frame
61
+ fps (float): Target processing FPS
62
+ output_base64 (bool): If true, returns the annotated frame as a base64 string
63
+
64
+ Returns:
65
+ tuple: (annotated_frame, list_of_triggered_alerts, metadata)
66
+ """
67
+ self.frame_idx += 1
68
+ current_time = self.frame_idx / fps
69
+ h_img, w_img, _ = frame.shape
70
+ start_time = time.time()
71
+
72
+ # 1. Person Detection (YOLO)
73
+ # Class 0 is person in COCO dataset
74
+ yolo_results = self.yolo(frame, classes=[0], conf=0.4, verbose=False)
75
+ detections = []
76
+ for res in yolo_results:
77
+ for box in res.boxes:
78
+ xyxy = box.xyxy[0].cpu().numpy()
79
+ conf = float(box.conf[0].cpu().numpy())
80
+ detections.append([xyxy[0], xyxy[1], xyxy[2], xyxy[3], conf])
81
+
82
+ detections = np.array(detections) if len(detections) > 0 else np.empty((0, 5))
83
+
84
+ # 2. Track Association (SORT)
85
+ tracked_objects = self.tracker.update(detections)
86
+ active_track_ids = [tid for _, tid in tracked_objects]
87
+
88
+ # Clean up running engine history for dead tracks
89
+ self.running_engine.clean_history(active_track_ids)
90
+
91
+ # 3. Analyze running/sprinting behavior for each person
92
+ new_behaviors = {}
93
+ active_alarms = set()
94
+ triggered_alerts = []
95
+
96
+ for bbox, track_id in tracked_objects:
97
+ x1, y1, x2, y2 = [int(c) for c in bbox]
98
+
99
+ # Clip bounds to frame borders
100
+ x1 = max(0, min(x1, w_img - 1))
101
+ y1 = max(0, min(y1, h_img - 1))
102
+ x2 = max(0, min(x2, w_img - 1))
103
+ y2 = max(0, min(y2, h_img - 1))
104
+
105
+ # Extract person crop
106
+ person_crop = frame[y1:y2, x1:x2]
107
+ crop_h, crop_w = person_crop.shape[:2]
108
+
109
+ # Improvement 3: Skip pose analysis on tiny distant people
110
+ # Small crops produce unreliable skeleton estimates that cause false positives
111
+ if crop_w < MIN_PERSON_CROP_SIZE or crop_h < MIN_PERSON_CROP_SIZE:
112
+ pose_result = self.running_engine._empty_result()
113
+ else:
114
+ # Analyze kinematics using MediaPipe Pose
115
+ pose_result = self.running_engine.analyze_pose(
116
+ person_crop, track_id, self.frame_idx, fps
117
+ )
118
+
119
+ # Store results
120
+ new_behaviors[track_id] = pose_result
121
+
122
+ # Handle running alarms
123
+ if pose_result["is_detected"]:
124
+ active_alarms.add("RUNNING")
125
+
126
+ # Check for alert trigger
127
+ alert = self.alert_manager.trigger_alert(
128
+ threat_type="running",
129
+ confidence=pose_result["confidence"],
130
+ camera_id=self.camera_id,
131
+ track_id=track_id,
132
+ bbox=bbox,
133
+ metadata=pose_result["metrics"],
134
+ current_time=current_time
135
+ )
136
+ if alert:
137
+ triggered_alerts.append(alert)
138
+
139
+ # 4. Violence Detection (VideoMAE over sliding frame clip)
140
+ # Store a resized version of the frame to keep memory usage low
141
+ resized_frame = cv2.resize(frame, (224, 224))
142
+ self.violence_buffer.append(resized_frame)
143
+ if len(self.violence_buffer) > VIOLENCE_FRAME_COUNT:
144
+ self.violence_buffer.pop(0)
145
+
146
+ violence_confidence = 0.0
147
+ violence_label = "normal"
148
+ is_violence_detected = False
149
+ raw_violence_score = 0.0
150
+
151
+ # Only run VideoMAE classification once buffer is full and matches stride
152
+ if len(self.violence_buffer) == VIOLENCE_FRAME_COUNT and len(tracked_objects) > 0:
153
+ if self.frame_idx % self.violence_stride == 0:
154
+ violence_res = self.violence_engine.predict(self.violence_buffer)
155
+ raw_violence_score = violence_res["confidence"] if violence_res["is_detected"] else 0.0
156
+
157
+ # Improvement 2: Exponential Moving Average smoothing
158
+ # Prevents confidence from jumping wildly between inference windows
159
+ self._violence_ema_score = (
160
+ VIOLENCE_EMA_ALPHA * raw_violence_score +
161
+ (1 - VIOLENCE_EMA_ALPHA) * self._violence_ema_score
162
+ )
163
+
164
+ if violence_res["is_detected"]:
165
+ # Violence detected — update state and record timestamp
166
+ self._violence_active = True
167
+ self._violence_confidence = self._violence_ema_score
168
+ self._violence_last_detected_time = current_time
169
+
170
+ # Trigger alert
171
+ alert = self.alert_manager.trigger_alert(
172
+ threat_type="violence",
173
+ confidence=self._violence_ema_score,
174
+ camera_id=self.camera_id,
175
+ metadata=violence_res["all_scores"],
176
+ current_time=current_time
177
+ )
178
+ if alert:
179
+ triggered_alerts.append(alert)
180
+
181
+ # Improvement 5: Violence Escalation tracking
182
+ if self._violence_ema_score >= VIOLENCE_ESCALATION_THRESHOLD:
183
+ if self._violence_sustained_start is None:
184
+ self._violence_sustained_start = current_time
185
+ elif (current_time - self._violence_sustained_start >= VIOLENCE_ESCALATION_SECONDS
186
+ and not self._violence_escalation_fired):
187
+ # Sustained high-confidence violence — fire escalation alert
188
+ esc_alert = self.alert_manager.trigger_alert(
189
+ threat_type="violence_escalated",
190
+ confidence=self._violence_ema_score,
191
+ camera_id=self.camera_id,
192
+ metadata={"sustained_seconds": current_time - self._violence_sustained_start},
193
+ current_time=current_time
194
+ )
195
+ if esc_alert:
196
+ triggered_alerts.append(esc_alert)
197
+ self._violence_escalation_fired = True
198
+ else:
199
+ # Below escalation threshold — reset sustained timer
200
+ self._violence_sustained_start = None
201
+ else:
202
+ # Improvement 1: Decay timer instead of instant off
203
+ # Violence lingers for VIOLENCE_DECAY_SECONDS after last positive detection
204
+ self._violence_ema_score *= 0.7 # Gentle decay of EMA score
205
+ if self._violence_sustained_start is not None:
206
+ self._violence_sustained_start = None
207
+ self._violence_escalation_fired = False
208
+
209
+ # Improvement 1: Apply decay timer — violence stays active for N seconds
210
+ # after the last positive detection even if current inference says "normal"
211
+ if self._violence_active:
212
+ elapsed_since_detection = current_time - self._violence_last_detected_time
213
+ if elapsed_since_detection < VIOLENCE_DECAY_SECONDS:
214
+ # Still within decay window — keep violence active
215
+ is_violence_detected = True
216
+ violence_confidence = self._violence_confidence
217
+ violence_label = "violence"
218
+ active_alarms.add("VIOLENCE")
219
+ else:
220
+ # Decay window expired — clear violence state
221
+ self._violence_active = False
222
+ self._violence_confidence = 0.0
223
+ self._violence_ema_score = 0.0
224
+ self._violence_sustained_start = None
225
+ self._violence_escalation_fired = False
226
+
227
+ # 5. Violence-Suppresses-Running Logic
228
+ # When violence IS detected, fighting body movements must NOT be labeled as running.
229
+ # Override any running behavior to "fighting" and remove false running alerts.
230
+ if is_violence_detected:
231
+ # Remove false "RUNNING" alarm — violence takes priority
232
+ active_alarms.discard("RUNNING")
233
+
234
+ # Override all tracked person behaviors from "running" to "fighting"
235
+ for track_id in new_behaviors:
236
+ bh = new_behaviors[track_id]
237
+ if bh["is_detected"] and bh["class_label"] == "running":
238
+ bh["class_label"] = "fighting"
239
+ bh["confidence"] = violence_confidence
240
+ bh["is_detected"] = True
241
+
242
+ # Remove any false running alerts from this frame
243
+ triggered_alerts = [a for a in triggered_alerts if a.threat_type != "running"]
244
+
245
+ # 6. Render Visual overlays
246
+ annotated_frame = frame.copy()
247
+
248
+ # Draw skeletons and labels for tracked people
249
+ annotated_frame = self.annotator.draw_predictions(
250
+ annotated_frame, tracked_objects, new_behaviors
251
+ )
252
+
253
+ # Draw active alert banner if any threat is active
254
+ if active_alarms:
255
+ annotated_frame = self.annotator.draw_alert_banners(
256
+ annotated_frame, list(active_alarms)
257
+ )
258
+
259
+ # Draw timestamp and brand logo
260
+ timestamp_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
261
+ annotated_frame = self.annotator.draw_timestamp_overlay(
262
+ annotated_frame, timestamp_str
263
+ )
264
+
265
+ # 7. Build response metadata
266
+ latency_ms = (time.time() - start_time) * 1000.0
267
+
268
+ # Serialize annotated frame to base64 if requested (useful for APIs)
269
+ frame_base64 = None
270
+ if output_base64:
271
+ _, buffer = cv2.imencode('.jpg', annotated_frame)
272
+ frame_base64 = base64.b64encode(buffer).decode('utf-8')
273
+
274
+ # Attach the base64 image to alerts generated in this frame
275
+ for alert in triggered_alerts:
276
+ alert.frame_image = frame_base64
277
+
278
+ metadata = {
279
+ "frame_idx": self.frame_idx,
280
+ "latency_ms": latency_ms,
281
+ "active_tracks_count": len(tracked_objects),
282
+ "threats_detected": list(active_alarms),
283
+ "violence_metrics": {
284
+ "detected": is_violence_detected,
285
+ "label": violence_label,
286
+ "confidence": violence_confidence
287
+ },
288
+ "tracks": [
289
+ {
290
+ "track_id": tid,
291
+ "bbox": [float(c) for c in bbox],
292
+ "behavior": new_behaviors[tid]["class_label"],
293
+ "behavior_confidence": new_behaviors[tid]["confidence"]
294
+ }
295
+ for bbox, tid in tracked_objects
296
+ ]
297
+ }
298
+
299
+ # Keep current behaviors state updated
300
+ self.current_behaviors = new_behaviors
301
+
302
+ return annotated_frame, triggered_alerts, metadata
suspicious_behavior/pipeline/video_processor.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cv2
2
+ import os
3
+ import time
4
+
5
+ class VideoProcessor:
6
+ """
7
+ Handles video ingestion, frame extraction, smart sampling,
8
+ and compilation of processed frames back to video files.
9
+ """
10
+ def __init__(self, target_fps=10.0):
11
+ self.target_fps = target_fps
12
+
13
+ def get_metadata(self, video_path):
14
+ """
15
+ Retrieves video metadata from the file.
16
+ """
17
+ cap = cv2.VideoCapture(video_path)
18
+ if not cap.isOpened():
19
+ raise FileNotFoundError(f"Could not open video file at {video_path}")
20
+
21
+ fps = cap.get(cv2.CAP_PROP_FPS)
22
+ frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
23
+ width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
24
+ height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
25
+ cap.release()
26
+
27
+ return {
28
+ "fps": fps,
29
+ "frame_count": frame_count,
30
+ "width": width,
31
+ "height": height,
32
+ "duration": frame_count / (fps + 1e-6)
33
+ }
34
+
35
+ def extract_frames_generator(self, video_path):
36
+ """
37
+ A generator that yields frames at target FPS.
38
+ Yields: (frame_idx, frame, timestamp)
39
+ """
40
+ cap = cv2.VideoCapture(video_path)
41
+ if not cap.isOpened():
42
+ raise FileNotFoundError(f"Could not open video file at {video_path}")
43
+
44
+ source_fps = cap.get(cv2.CAP_PROP_FPS)
45
+ if source_fps <= 0:
46
+ source_fps = 30.0 # Fallback
47
+
48
+ # Calculate frame step based on target FPS
49
+ frame_step = max(1, int(round(source_fps / self.target_fps)))
50
+
51
+ frame_idx = 0
52
+ while True:
53
+ ret, frame = cap.read()
54
+ if not ret:
55
+ break
56
+
57
+ # Yield frame if it matches the sampling step
58
+ if frame_idx % frame_step == 0:
59
+ timestamp = frame_idx / source_fps
60
+ yield frame_idx, frame, timestamp
61
+
62
+ frame_idx += 1
63
+
64
+ cap.release()
65
+
66
+ def write_frames_to_video(self, frames, output_path, fps=10.0, frame_size=None):
67
+ """
68
+ Compiles a list or generator of frames into an output video file (MP4 format).
69
+ """
70
+ if len(frames) == 0:
71
+ return
72
+
73
+ if frame_size is None:
74
+ height, width, _ = frames[0].shape
75
+ frame_size = (width, height)
76
+
77
+ # Use mp4v codec for broad compatibility
78
+ fourcc = cv2.VideoWriter_fourcc(*'mp4v')
79
+ out = cv2.VideoWriter(output_path, fourcc, fps, frame_size)
80
+
81
+ for frame in frames:
82
+ # Ensure frame matches the target dimensions
83
+ if (frame.shape[1], frame.shape[0]) != frame_size:
84
+ frame = cv2.resize(frame, frame_size)
85
+ out.write(frame)
86
+
87
+ out.release()
88
+ print(f"[VideoProcessor] Video saved successfully to {output_path} ({len(frames)} frames)")
suspicious_behavior/tracking/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Tracking submodule
suspicious_behavior/tracking/sort_tracker.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ from scipy.optimize import linear_sum_assignment
3
+ from suspicious_behavior.tracking.track import Track
4
+ from suspicious_behavior.config import TRACKER_MAX_AGE, TRACKER_MIN_HITS, TRACKER_IOU_THRESHOLD
5
+
6
+ def iou_batch(bboxes1, bboxes2):
7
+ """
8
+ Computes Intersection over Union (IoU) between two sets of bounding boxes.
9
+
10
+ Args:
11
+ bboxes1 (numpy.ndarray): N x 4 bounding boxes
12
+ bboxes2 (numpy.ndarray): M x 4 bounding boxes
13
+
14
+ Returns:
15
+ numpy.ndarray: N x M IoU matrix
16
+ """
17
+ if len(bboxes1) == 0 or len(bboxes2) == 0:
18
+ return np.empty((len(bboxes1), len(bboxes2)))
19
+
20
+ # Expand dimensions for vectorization
21
+ bboxes2 = np.expand_dims(bboxes2, 0)
22
+ bboxes1 = np.expand_dims(bboxes1, 1)
23
+
24
+ # Calculate intersections
25
+ xx1 = np.maximum(bboxes1[..., 0], bboxes2[..., 0])
26
+ yy1 = np.maximum(bboxes1[..., 1], bboxes2[..., 1])
27
+ xx2 = np.minimum(bboxes1[..., 2], bboxes2[..., 2])
28
+ yy2 = np.minimum(bboxes1[..., 3], bboxes2[..., 3])
29
+
30
+ w = np.maximum(0.0, xx2 - xx1)
31
+ h = np.maximum(0.0, yy2 - yy1)
32
+ intersection = w * h
33
+
34
+ # Calculate union
35
+ area1 = (bboxes1[..., 2] - bboxes1[..., 0]) * (bboxes1[..., 3] - bboxes1[..., 1])
36
+ area2 = (bboxes2[..., 2] - bboxes2[..., 0]) * (bboxes2[..., 3] - bboxes2[..., 1])
37
+ union = area1 + area2 - intersection
38
+
39
+ return intersection / (union + 1e-6)
40
+
41
+
42
+ class SortTracker:
43
+ """
44
+ Simple Online and Realtime Tracking (SORT) orchestrator.
45
+ Associates object detections to persistent object tracks.
46
+ """
47
+ def __init__(self, max_age=TRACKER_MAX_AGE, min_hits=TRACKER_MIN_HITS, iou_threshold=TRACKER_IOU_THRESHOLD):
48
+ self.max_age = max_age
49
+ self.min_hits = min_hits
50
+ self.iou_threshold = iou_threshold
51
+ self.tracks = []
52
+ self.frame_count = 0
53
+
54
+ def update(self, detections):
55
+ """
56
+ Updates the tracker with new detections.
57
+
58
+ Args:
59
+ detections (numpy.ndarray): N x 5 array containing bounding boxes and confidences [x1, y1, x2, y2, score]
60
+
61
+ Returns:
62
+ list: List of active tracks containing: [x1, y1, x2, y2, track_id]
63
+ """
64
+ self.frame_count += 1
65
+
66
+ # Get predictions from existing tracks
67
+ predicted_bboxes = []
68
+ to_delete = []
69
+
70
+ for idx, track in enumerate(self.tracks):
71
+ pos = track.predict()[0]
72
+ # Handle invalid bounding boxes from prediction
73
+ if np.any(np.isnan(pos)):
74
+ to_delete.append(idx)
75
+ else:
76
+ predicted_bboxes.append(pos)
77
+
78
+ # Remove corrupted predictions
79
+ for idx in sorted(to_delete, reverse=True):
80
+ self.tracks.pop(idx)
81
+
82
+ predicted_bboxes = np.array(predicted_bboxes)
83
+
84
+ # Match predicted tracks to current detections
85
+ matched, unmatched_detections, unmatched_tracks = self._associate_detections_to_tracks(
86
+ detections, predicted_bboxes
87
+ )
88
+
89
+ # Update matched tracks
90
+ for track_idx, detection_idx in matched:
91
+ self.tracks[track_idx].update(detections[detection_idx][:4])
92
+
93
+ # Create new tracks for unmatched detections
94
+ for detection_idx in unmatched_detections:
95
+ new_track = Track(detections[detection_idx][:4])
96
+ self.tracks.append(new_track)
97
+
98
+ # Filter out dead tracks
99
+ active_tracks = []
100
+ to_remove = []
101
+
102
+ for idx, track in enumerate(self.tracks):
103
+ # Keep tracks that were updated recently
104
+ if track.time_since_update > self.max_age:
105
+ to_remove.append(idx)
106
+ else:
107
+ # Confirmed if it has enough hits and is active
108
+ if track.hits >= self.min_hits or self.frame_count <= self.min_hits:
109
+ bbox = track.get_state()
110
+ active_tracks.append((bbox, track.id))
111
+
112
+ # Remove dead tracks in reverse order to keep indices correct
113
+ for idx in sorted(to_remove, reverse=True):
114
+ self.tracks.pop(idx)
115
+
116
+ return active_tracks
117
+
118
+ def _associate_detections_to_tracks(self, detections, predicted_tracks):
119
+ """
120
+ Associates detections to predicted tracks using the Hungarian algorithm.
121
+ """
122
+ if len(predicted_tracks) == 0:
123
+ return np.empty((0, 2), dtype=int), np.arange(len(detections)), np.empty((0,), dtype=int)
124
+
125
+ if len(detections) == 0:
126
+ return np.empty((0, 2), dtype=int), np.empty((0,), dtype=int), np.arange(len(predicted_tracks))
127
+
128
+ # Compute cost matrix (1 - IoU)
129
+ iou_matrix = iou_batch(detections[:, :4], predicted_tracks)
130
+
131
+ # SciPy Hungarian Algorithm solver
132
+ row_ind, col_ind = linear_sum_assignment(-iou_matrix)
133
+
134
+ matched = []
135
+ unmatched_detections = []
136
+ unmatched_tracks = []
137
+
138
+ # Filter matches based on IoU threshold
139
+ matched_detections_set = set()
140
+ matched_tracks_set = set()
141
+
142
+ for d, t in zip(row_ind, col_ind):
143
+ if iou_matrix[d, t] >= self.iou_threshold:
144
+ matched.append((t, d))
145
+ matched_detections_set.add(d)
146
+ matched_tracks_set.add(t)
147
+
148
+ # Find unmatched detections
149
+ for d in range(len(detections)):
150
+ if d not in matched_detections_set:
151
+ unmatched_detections.append(d)
152
+
153
+ # Find unmatched tracks
154
+ for t in range(len(predicted_tracks)):
155
+ if t not in matched_tracks_set:
156
+ unmatched_tracks.append(t)
157
+
158
+ return np.array(matched), np.array(unmatched_detections), np.array(unmatched_tracks)
suspicious_behavior/tracking/track.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ from filterpy.kalman import KalmanFilter
3
+
4
+ class Track:
5
+ """
6
+ Represents an individual tracked object (person) with a Kalman Filter.
7
+ Adapts the classical SORT tracking state vector.
8
+ """
9
+ count = 0
10
+
11
+ def __init__(self, bbox):
12
+ """
13
+ Initializes a track using an initial bounding box.
14
+
15
+ Args:
16
+ bbox (list/numpy.ndarray): Bounding box [x1, y1, x2, y2]
17
+ """
18
+ # State vector: [u, v, s, r, u_dot, v_dot, s_dot]^T
19
+ # u, v: center coordinates of the bounding box
20
+ # s: scale (area)
21
+ # r: aspect ratio (width / height)
22
+ # u_dot, v_dot, s_dot: corresponding velocities
23
+ self.kf = KalmanFilter(dim_x=7, dim_z=4)
24
+
25
+ # State Transition Matrix F
26
+ self.kf.F = np.array([
27
+ [1, 0, 0, 0, 1, 0, 0],
28
+ [0, 1, 0, 0, 0, 1, 0],
29
+ [0, 0, 1, 0, 0, 0, 1],
30
+ [0, 0, 0, 1, 0, 0, 0],
31
+ [0, 0, 0, 0, 1, 0, 0],
32
+ [0, 0, 0, 0, 0, 1, 0],
33
+ [0, 0, 0, 0, 0, 0, 1]
34
+ ])
35
+
36
+ # Measurement Matrix H
37
+ self.kf.H = np.array([
38
+ [1, 0, 0, 0, 0, 0, 0],
39
+ [0, 1, 0, 0, 0, 0, 0],
40
+ [0, 0, 1, 0, 0, 0, 0],
41
+ [0, 0, 0, 1, 0, 0, 0]
42
+ ])
43
+
44
+ # Covariance matrices
45
+ self.kf.R[2:, 2:] *= 10.0
46
+ self.kf.P[4:, 4:] *= 1000.0 # High uncertainty to unobserved initial velocities
47
+ self.kf.P *= 10.0
48
+ self.kf.Q[-1, -1] *= 0.01
49
+ self.kf.Q[4:, 4:] *= 0.01
50
+
51
+ # Initialize state with measured bounding box
52
+ self.kf.x[:4] = self.bbox_to_z(bbox)
53
+
54
+ self.id = Track.count
55
+ Track.count += 1
56
+
57
+ self.time_since_update = 0
58
+ self.history = []
59
+ self.hits = 0
60
+ self.hit_streak = 0
61
+ self.age = 0
62
+
63
+ def predict(self):
64
+ """
65
+ Advances the state estimate using the Kalman filter prediction step.
66
+ """
67
+ if (self.kf.x[6] + self.kf.x[2]) <= 0:
68
+ self.kf.x[6] *= 0.0
69
+ self.kf.predict()
70
+ self.age += 1
71
+
72
+ if self.time_since_update > 0:
73
+ self.hit_streak = 0
74
+ self.time_since_update += 1
75
+
76
+ predicted_bbox = self.z_to_bbox(self.kf.x)
77
+ self.history.append(predicted_bbox)
78
+ return self.history[-1]
79
+
80
+ def update(self, bbox):
81
+ """
82
+ Updates the track state with an observed bounding box measurement.
83
+
84
+ Args:
85
+ bbox (list/numpy.ndarray): Observed bounding box [x1, y1, x2, y2]
86
+ """
87
+ self.time_since_update = 0
88
+ self.history = []
89
+ self.hits += 1
90
+ self.hit_streak += 1
91
+ self.kf.update(self.bbox_to_z(bbox))
92
+
93
+ def get_state(self):
94
+ """
95
+ Returns the current bounding box estimate.
96
+
97
+ Returns:
98
+ numpy.ndarray: [x1, y1, x2, y2]
99
+ """
100
+ return self.z_to_bbox(self.kf.x)[0]
101
+
102
+ @staticmethod
103
+ def bbox_to_z(bbox):
104
+ """
105
+ Converts [x1, y1, x2, y2] bounding box to [u, v, s, r] measurement.
106
+ """
107
+ w = bbox[2] - bbox[0]
108
+ h = bbox[3] - bbox[1]
109
+ x = bbox[0] + w / 2.0
110
+ y = bbox[1] + h / 2.0
111
+ s = w * h
112
+ r = w / (float(h) + 1e-6)
113
+ return np.array([x, y, s, r]).reshape((4, 1))
114
+
115
+ @staticmethod
116
+ def z_to_bbox(x, score=None):
117
+ """
118
+ Converts [u, v, s, r] state estimate back to [x1, y1, x2, y2] bounding box.
119
+ """
120
+ w = np.sqrt(x[2] * x[3])
121
+ h = x[2] / (w + 1e-6)
122
+
123
+ x1 = x[0] - w / 2.0
124
+ y1 = x[1] - h / 2.0
125
+ x2 = x[0] + w / 2.0
126
+ y2 = x[1] + h / 2.0
127
+
128
+ if score is None:
129
+ return np.array([x1, y1, x2, y2]).reshape((1, 4))
130
+ else:
131
+ return np.array([x1, y1, x2, y2, score]).reshape((1, 5))
testMOdel.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+
4
+ def main():
5
+ try:
6
+ from ultralytics import YOLO
7
+ except ImportError as exc:
8
+ raise SystemExit(
9
+ "Ultralytics is not installed in this environment.\n"
10
+ "Run: pip install ultralytics opencv-python pillow"
11
+ ) from exc
12
+
13
+ root = Path(__file__).resolve().parent
14
+ model_path = root / "model" / "best.pt"
15
+ samples_dir = root / "samples"
16
+ output_dir = root / "outputs"
17
+ output_dir.mkdir(exist_ok=True)
18
+
19
+ if not model_path.exists():
20
+ raise SystemExit(f"Model file not found: {model_path}")
21
+
22
+ image_paths = sorted(
23
+ list(samples_dir.glob("*.jpg"))
24
+ + list(samples_dir.glob("*.jpeg"))
25
+ + list(samples_dir.glob("*.png"))
26
+ )
27
+ if not image_paths:
28
+ raise SystemExit(f"No test images found in: {samples_dir}")
29
+
30
+ model = YOLO(str(model_path))
31
+ print(f"Loaded model: {model_path}")
32
+ print(f"Model classes: {model.names}")
33
+ print()
34
+
35
+ for image_path in image_paths:
36
+ print("=" * 80)
37
+ print(f"Testing: {image_path.name}")
38
+
39
+ results = model.predict(
40
+ source=str(image_path),
41
+ imgsz=960,
42
+ conf=0.25,
43
+ save=False,
44
+ verbose=False,
45
+ )
46
+
47
+ result = results[0]
48
+ detections = []
49
+ for box in result.boxes:
50
+ cls_id = int(box.cls[0])
51
+ conf = float(box.conf[0])
52
+ label = model.names.get(cls_id, str(cls_id))
53
+ detections.append((label, conf))
54
+
55
+ if detections:
56
+ for label, conf in detections:
57
+ print(f"- {label}: {conf:.3f}")
58
+ else:
59
+ print("- No detections above confidence threshold")
60
+
61
+ annotated = result.plot()
62
+ output_path = output_dir / f"{image_path.stem}_detected.jpg"
63
+
64
+ try:
65
+ import cv2
66
+ except ImportError as exc:
67
+ raise SystemExit(
68
+ "OpenCV is not installed in this environment.\n"
69
+ "Run: pip install opencv-python"
70
+ ) from exc
71
+
72
+ cv2.imwrite(str(output_path), annotated)
73
+ print(f"Saved: {output_path}")
74
+
75
+
76
+ if __name__ == "__main__":
77
+ main()
tests/analyze_sample.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Analyze 'sample video.mp4' through the SimShieldAI suspicious behavior pipeline.
3
+ Prints detailed per-frame diagnostics for violence and running detection.
4
+ """
5
+ import os
6
+ import sys
7
+ import time
8
+ import cv2
9
+ import numpy as np
10
+
11
+ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
12
+
13
+ from suspicious_behavior.pipeline.frame_analyzer import FrameAnalyzer
14
+ from suspicious_behavior.pipeline.video_processor import VideoProcessor
15
+
16
+
17
+ def main():
18
+ video_path = r"C:\Users\Admin\Downloads\sample video.mp4"
19
+
20
+ print("=" * 70)
21
+ print("SimShieldAI — Suspicious Behavior Detection Analysis")
22
+ print(f"Video: {video_path}")
23
+ print("=" * 70)
24
+
25
+ # Get video metadata
26
+ processor = VideoProcessor(target_fps=10.0)
27
+ metadata = processor.get_metadata(video_path)
28
+ print(f"Resolution: {metadata['width']}x{metadata['height']}")
29
+ print(f"Duration: {metadata['duration']:.1f}s | Frames: {metadata['frame_count']} @ {metadata['fps']:.1f} FPS")
30
+ print(f"Processing at 10 FPS (every {int(metadata['fps']/10)} frames)")
31
+ print("=" * 70)
32
+
33
+ # Initialize analyzer
34
+ print("\n[Init] Loading models (YOLOv8n + VideoMAE + MediaPipe)...")
35
+ analyzer = FrameAnalyzer(camera_id="test_cam", violence_stride=4)
36
+
37
+ # Reset state
38
+ analyzer.frame_idx = 0
39
+ analyzer.violence_buffer.clear()
40
+ analyzer.alert_manager.reset_cooldowns()
41
+
42
+ # Process video
43
+ frames_processed = 0
44
+ violence_scores = []
45
+ running_detections = []
46
+ all_alerts = []
47
+ output_frames = []
48
+
49
+ start_time = time.time()
50
+
51
+ print("\n--- Per-Frame Analysis ---\n")
52
+
53
+ for f_idx, frame, timestamp in processor.extract_frames_generator(video_path):
54
+ annotated, frame_alerts, frame_meta = analyzer.analyze(frame, fps=10.0, output_base64=False)
55
+ frames_processed += 1
56
+ output_frames.append(annotated)
57
+
58
+ # Collect metrics
59
+ v_meta = frame_meta["violence_metrics"]
60
+ if v_meta["confidence"] > 0:
61
+ violence_scores.append({
62
+ "frame": f_idx,
63
+ "time": timestamp,
64
+ "label": v_meta["label"],
65
+ "conf": v_meta["confidence"]
66
+ })
67
+
68
+ for track in frame_meta["tracks"]:
69
+ running_detections.append({
70
+ "frame": f_idx,
71
+ "time": timestamp,
72
+ "track_id": track["track_id"],
73
+ "behavior": track["behavior"],
74
+ "conf": track["behavior_confidence"]
75
+ })
76
+
77
+ for alert in frame_alerts:
78
+ all_alerts.append({"time": timestamp, "alert": alert})
79
+
80
+ # Print frame details
81
+ tracks_info = ", ".join([
82
+ f"ID{t['track_id']}:{t['behavior']}({t['behavior_confidence']*100:.0f}%)"
83
+ for t in frame_meta["tracks"]
84
+ ]) or "no people"
85
+
86
+ violence_info = ""
87
+ if v_meta['confidence'] > 0:
88
+ emoji = "🔴" if v_meta['detected'] else "🟢"
89
+ violence_info = f" | {emoji} Violence: {v_meta['label']}({v_meta['confidence']*100:.1f}%)"
90
+
91
+ alert_info = ""
92
+ if frame_meta['threats_detected']:
93
+ alert_info = f" | ⚠️ THREATS: {frame_meta['threats_detected']}"
94
+
95
+ print(f" [{f_idx:04d}] {timestamp:5.1f}s | {frame_meta['active_tracks_count']} people | "
96
+ f"[{tracks_info}]{violence_info}{alert_info}")
97
+
98
+ elapsed = time.time() - start_time
99
+
100
+ # Save annotated output
101
+ os.makedirs("outputs", exist_ok=True)
102
+ output_path = os.path.join("outputs", "sample_video_annotated.mp4")
103
+ processor.write_frames_to_video(
104
+ output_frames, output_path, fps=10.0,
105
+ frame_size=(metadata['width'], metadata['height'])
106
+ )
107
+
108
+ # Print summary
109
+ print("\n" + "=" * 70)
110
+ print("ANALYSIS SUMMARY")
111
+ print("=" * 70)
112
+ print(f"Frames processed: {frames_processed}")
113
+ print(f"Processing time: {elapsed:.1f}s ({frames_processed/elapsed:.1f} FPS)")
114
+
115
+ print(f"\n--- Violence Detection (VideoMAE) ---")
116
+ if violence_scores:
117
+ confs = [s["conf"] for s in violence_scores]
118
+ violence_detected = [s for s in violence_scores if s["label"] != "normal"]
119
+ normal_scores = [s for s in violence_scores if s["label"] == "normal"]
120
+
121
+ print(f"Total inference runs: {len(violence_scores)}")
122
+ if violence_detected:
123
+ print(f"🔴 VIOLENCE DETECTED in {len(violence_detected)} frames:")
124
+ for v in violence_detected:
125
+ print(f" Frame {v['frame']:04d} ({v['time']:.1f}s): {v['label']} at {v['conf']*100:.1f}%")
126
+ else:
127
+ print(f"🟢 No violence detected. All {len(normal_scores)} checks returned 'normal'")
128
+ if normal_scores:
129
+ normal_confs = [s["conf"] for s in normal_scores]
130
+ print(f" Normal confidence: min={min(normal_confs)*100:.1f}%, max={max(normal_confs)*100:.1f}%, avg={np.mean(normal_confs)*100:.1f}%")
131
+ else:
132
+ print("⚠️ VideoMAE never ran (need 16 frames + people in scene)")
133
+
134
+ print(f"\n--- Running Detection (MediaPipe) ---")
135
+ if running_detections:
136
+ running_found = [r for r in running_detections if r["behavior"] == "running"]
137
+ walking = [r for r in running_detections if r["behavior"] == "walking/standing"]
138
+ unknown = [r for r in running_detections if r["behavior"] == "unknown"]
139
+
140
+ print(f"Total person-frame analyses: {len(running_detections)}")
141
+ if running_found:
142
+ print(f"🟠 RUNNING DETECTED in {len(running_found)} person-frames:")
143
+ for r in running_found[:10]:
144
+ print(f" Frame {r['frame']:04d} ({r['time']:.1f}s): ID{r['track_id']} running at {r['conf']*100:.1f}%")
145
+ else:
146
+ print(f"🟢 No running detected. {len(walking)} walking/standing, {len(unknown)} unknown")
147
+ else:
148
+ print("⚠️ No people detected in any frame")
149
+
150
+ print(f"\n--- Alerts ---")
151
+ if all_alerts:
152
+ print(f"🚨 {len(all_alerts)} ALERTS TRIGGERED:")
153
+ for a in all_alerts:
154
+ alert = a["alert"]
155
+ print(f" [{a['time']:.1f}s] [{alert.severity}] {alert.threat_type} "
156
+ f"(confidence: {alert.confidence*100:.1f}%)")
157
+ else:
158
+ print("No alerts triggered")
159
+
160
+ print(f"\n--- Output ---")
161
+ print(f"Annotated video saved to: {os.path.abspath(output_path)}")
162
+ print("=" * 70)
163
+
164
+
165
+ if __name__ == "__main__":
166
+ main()
tests/test_accuracy.py ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test script to verify violence/fighting detection accuracy.
3
+ Downloads sample fight clips and runs them through the pipeline,
4
+ printing detailed per-frame analysis to verify detection works.
5
+ """
6
+ import os
7
+ import sys
8
+ import time
9
+ import cv2
10
+ import numpy as np
11
+
12
+ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
13
+
14
+ from suspicious_behavior.pipeline.frame_analyzer import FrameAnalyzer
15
+ from suspicious_behavior.pipeline.video_processor import VideoProcessor
16
+
17
+
18
+ def create_synthetic_fight_clip(output_path, num_frames=48, width=640, height=480):
19
+ """
20
+ Creates a synthetic video simulating two people in close proximity with
21
+ rapid arm movements — this tests whether the VideoMAE model responds
22
+ to motion patterns that resemble fighting.
23
+ """
24
+ fourcc = cv2.VideoWriter_fourcc(*'mp4v')
25
+ out = cv2.VideoWriter(output_path, fourcc, 10.0, (width, height))
26
+
27
+ for i in range(num_frames):
28
+ # Create a frame with simulated scene
29
+ frame = np.zeros((height, width, 3), dtype=np.uint8)
30
+ # Add gray floor
31
+ frame[height//2:, :] = (80, 80, 80)
32
+ # Add background variation
33
+ frame[:height//2, :] = (40, 30, 20)
34
+
35
+ # Person 1: oscillating arm movements
36
+ cx1 = width // 3
37
+ cy1 = height // 2 - 50
38
+ arm_offset = int(40 * np.sin(i * 0.8))
39
+
40
+ # Body
41
+ cv2.rectangle(frame, (cx1-20, cy1-60), (cx1+20, cy1+60), (150, 100, 80), -1)
42
+ # Head
43
+ cv2.circle(frame, (cx1, cy1-80), 20, (180, 140, 120), -1)
44
+ # Arms (rapid movement)
45
+ cv2.line(frame, (cx1+20, cy1-30), (cx1+60+arm_offset, cy1-50+abs(arm_offset)), (150, 100, 80), 8)
46
+ cv2.line(frame, (cx1-20, cy1-30), (cx1-60-arm_offset, cy1-40-abs(arm_offset)//2), (150, 100, 80), 8)
47
+ # Legs
48
+ cv2.line(frame, (cx1-10, cy1+60), (cx1-20, cy1+120), (100, 70, 50), 8)
49
+ cv2.line(frame, (cx1+10, cy1+60), (cx1+20, cy1+120), (100, 70, 50), 8)
50
+
51
+ # Person 2: close proximity, also swinging arms
52
+ cx2 = width // 3 + 100
53
+ cy2 = height // 2 - 40
54
+ arm_offset2 = int(35 * np.cos(i * 0.9))
55
+
56
+ # Body
57
+ cv2.rectangle(frame, (cx2-20, cy2-60), (cx2+20, cy2+60), (80, 120, 160), -1)
58
+ # Head
59
+ cv2.circle(frame, (cx2, cy2-80), 20, (120, 160, 180), -1)
60
+ # Arms
61
+ cv2.line(frame, (cx2-20, cy2-30), (cx2-70+arm_offset2, cy2-60+abs(arm_offset2)), (80, 120, 160), 8)
62
+ cv2.line(frame, (cx2+20, cy2-30), (cx2+50-arm_offset2, cy2-30-abs(arm_offset2)//2), (80, 120, 160), 8)
63
+ # Legs
64
+ cv2.line(frame, (cx2-10, cy2+60), (cx2-15, cy2+120), (60, 90, 120), 8)
65
+ cv2.line(frame, (cx2+10, cy2+60), (cx2+15, cy2+120), (60, 90, 120), 8)
66
+
67
+ out.write(frame)
68
+
69
+ out.release()
70
+ print(f"[Test] Created synthetic fight clip: {output_path} ({num_frames} frames)")
71
+
72
+
73
+ def test_with_video(video_path, analyzer, label="Unknown"):
74
+ """
75
+ Runs video through the pipeline and prints detailed frame-by-frame diagnostics.
76
+ """
77
+ if not os.path.exists(video_path):
78
+ print(f"[Test] Skipping {label}: file not found at {video_path}")
79
+ return
80
+
81
+ processor = VideoProcessor(target_fps=10.0)
82
+ metadata = processor.get_metadata(video_path)
83
+ print(f"\n{'='*60}")
84
+ print(f"Testing: {label}")
85
+ print(f"File: {video_path}")
86
+ print(f"Duration: {metadata['duration']:.1f}s, {metadata['frame_count']} frames @ {metadata['fps']:.1f} FPS")
87
+ print(f"{'='*60}")
88
+
89
+ # Reset analyzer state for clean test
90
+ analyzer.frame_idx = 0
91
+ analyzer.violence_buffer.clear()
92
+ analyzer.alert_manager.reset_cooldowns()
93
+
94
+ frames_processed = 0
95
+ violence_scores = []
96
+ running_scores = []
97
+ alerts = []
98
+
99
+ output_dir = "outputs"
100
+ os.makedirs(output_dir, exist_ok=True)
101
+ output_frames = []
102
+
103
+ for f_idx, frame, timestamp in processor.extract_frames_generator(video_path):
104
+ annotated, frame_alerts, frame_meta = analyzer.analyze(frame, fps=10.0, output_base64=False)
105
+ frames_processed += 1
106
+ output_frames.append(annotated)
107
+
108
+ # Collect violence and running metrics
109
+ v_meta = frame_meta["violence_metrics"]
110
+ if v_meta["confidence"] > 0:
111
+ violence_scores.append(v_meta["confidence"])
112
+
113
+ for track in frame_meta["tracks"]:
114
+ running_scores.append(track["behavior_confidence"])
115
+
116
+ for alert in frame_alerts:
117
+ alerts.append((timestamp, alert))
118
+
119
+ # Print every frame's details for full diagnostic visibility
120
+ tracks_info = ", ".join([
121
+ f"ID{t['track_id']}:{t['behavior']}({t['behavior_confidence']*100:.0f}%)"
122
+ for t in frame_meta["tracks"]
123
+ ]) or "none"
124
+
125
+ violence_info = f"violence={v_meta['label']}({v_meta['confidence']*100:.1f}%)" if v_meta['confidence'] > 0 else ""
126
+
127
+ print(f" Frame {f_idx:04d} ({timestamp:5.1f}s) | People: {frame_meta['active_tracks_count']} | "
128
+ f"Tracks: [{tracks_info}] {violence_info} "
129
+ f"| Threats: {frame_meta['threats_detected']}")
130
+
131
+ # Save annotated output
132
+ safe_label = label.replace(" ", "_").replace("/", "_")
133
+ output_path = os.path.join(output_dir, f"test_{safe_label}.mp4")
134
+ processor.write_frames_to_video(output_frames, output_path, fps=10.0,
135
+ frame_size=(metadata['width'], metadata['height']))
136
+
137
+ # Print summary
138
+ print(f"\n--- Results for: {label} ---")
139
+ print(f"Frames processed: {frames_processed}")
140
+ if violence_scores:
141
+ print(f"Violence scores: min={min(violence_scores)*100:.1f}%, max={max(violence_scores)*100:.1f}%, avg={np.mean(violence_scores)*100:.1f}%")
142
+ else:
143
+ print("Violence scores: No VideoMAE inference ran (need 16 frames + people in scene)")
144
+ if running_scores:
145
+ print(f"Running scores: min={min(running_scores)*100:.1f}%, max={max(running_scores)*100:.1f}%, avg={np.mean(running_scores)*100:.1f}%")
146
+ else:
147
+ print("Running scores: No people detected")
148
+ print(f"Alerts triggered: {len(alerts)}")
149
+ for ts, alert in alerts:
150
+ print(f" [{ts:.1f}s] [{alert.severity}] {alert.threat_type} ({alert.confidence*100:.1f}%)")
151
+ print(f"Output: {output_path}")
152
+
153
+
154
+ def main():
155
+ print("=" * 60)
156
+ print("SimShieldAI — Violence & Running Detection Accuracy Test")
157
+ print("=" * 60)
158
+
159
+ # Create synthetic test videos
160
+ os.makedirs("samples", exist_ok=True)
161
+
162
+ # 1. Synthetic fight clip (48 frames = ~5s at 10fps)
163
+ synthetic_fight = "samples/synthetic_fight.mp4"
164
+ create_synthetic_fight_clip(synthetic_fight, num_frames=48)
165
+
166
+ # 2. Synthetic normal/walking clip (person standing still)
167
+ synthetic_normal = "samples/synthetic_normal.mp4"
168
+ create_synthetic_normal_clip(synthetic_normal, num_frames=48)
169
+
170
+ print("\n[Test] Initializing FrameAnalyzer (loading all models)...")
171
+ analyzer = FrameAnalyzer(camera_id="test_cam", violence_stride=4)
172
+
173
+ # Test 1: Client CCTV (should be NORMAL — no fighting)
174
+ test_with_video("samples/FullSizeRender.mov", analyzer, label="Client CCTV (Normal)")
175
+
176
+ # Test 2: Synthetic fight (test if the system processes it)
177
+ test_with_video(synthetic_fight, analyzer, label="Synthetic Fight")
178
+
179
+ # Test 3: Synthetic normal (test false positive rate)
180
+ test_with_video(synthetic_normal, analyzer, label="Synthetic Normal")
181
+
182
+ print("\n" + "=" * 60)
183
+ print("All tests complete!")
184
+ print("=" * 60)
185
+ print("\nIMPORTANT: To properly verify violence detection accuracy, you need")
186
+ print("real fighting video clips. Download from one of these sources:")
187
+ print(" 1. Real-Life Violence Situations (RLVS) Dataset on Kaggle:")
188
+ print(" https://www.kaggle.com/datasets/mohamedmustafa/real-life-violence-situations-dataset")
189
+ print(" 2. RWF-2000 Dataset on Kaggle:")
190
+ print(" https://www.kaggle.com/datasets/hwang033/rwf2000-video-database-for-violence-detection")
191
+ print(" 3. Hockey Fight Dataset on Kaggle:")
192
+ print(" https://www.kaggle.com/datasets/yassershrief/hockey-fight-vidoes")
193
+ print("\nDownload a few 'fight' and 'non-fight' MP4 clips and place them in:")
194
+ print(" c:\\Users\\Admin\\Desktop\\testing_model\\samples\\")
195
+ print("Then re-run this script to verify detection accuracy.")
196
+
197
+
198
+ def create_synthetic_normal_clip(output_path, num_frames=48, width=640, height=480):
199
+ """
200
+ Creates a synthetic video with a single person standing still —
201
+ this should NOT trigger any violence or running alerts.
202
+ """
203
+ fourcc = cv2.VideoWriter_fourcc(*'mp4v')
204
+ out = cv2.VideoWriter(output_path, fourcc, 10.0, (width, height))
205
+
206
+ for i in range(num_frames):
207
+ frame = np.zeros((height, width, 3), dtype=np.uint8)
208
+ frame[:] = (60, 50, 40) # Dark background
209
+ frame[height//2:, :] = (80, 80, 80) # Floor
210
+
211
+ # Static person standing
212
+ cx = width // 2
213
+ cy = height // 2 - 30
214
+
215
+ # Body
216
+ cv2.rectangle(frame, (cx-18, cy-55), (cx+18, cy+55), (140, 110, 90), -1)
217
+ # Head
218
+ cv2.circle(frame, (cx, cy-75), 18, (170, 140, 120), -1)
219
+ # Arms down
220
+ cv2.line(frame, (cx-18, cy-25), (cx-30, cy+40), (140, 110, 90), 7)
221
+ cv2.line(frame, (cx+18, cy-25), (cx+30, cy+40), (140, 110, 90), 7)
222
+ # Legs
223
+ cv2.line(frame, (cx-8, cy+55), (cx-12, cy+115), (100, 80, 60), 7)
224
+ cv2.line(frame, (cx+8, cy+55), (cx+12, cy+115), (100, 80, 60), 7)
225
+
226
+ out.write(frame)
227
+
228
+ out.release()
229
+ print(f"[Test] Created synthetic normal clip: {output_path} ({num_frames} frames)")
230
+
231
+
232
+ if __name__ == "__main__":
233
+ main()
tests/validate_system.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import time
4
+
5
+ # Add project root to sys.path
6
+ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
7
+
8
+ from suspicious_behavior.pipeline.frame_analyzer import FrameAnalyzer
9
+ from suspicious_behavior.pipeline.video_processor import VideoProcessor
10
+
11
+ def main():
12
+ video_path = "samples/FullSizeRender.mov"
13
+ output_dir = "outputs"
14
+ output_path = os.path.join(output_dir, "FullSizeRender_annotated.mp4")
15
+
16
+ os.makedirs(output_dir, exist_ok=True)
17
+
18
+ if not os.path.exists(video_path):
19
+ print(f"Error: Sample video not found at {video_path}")
20
+ sys.exit(1)
21
+
22
+ print(f"[Validation] Opening video file: {video_path}...")
23
+ processor = VideoProcessor(target_fps=10.0)
24
+ metadata = processor.get_metadata(video_path)
25
+ print(f"[Validation] Video metadata: {metadata}")
26
+
27
+ print("[Validation] Initializing FrameAnalyzer (loading models)...")
28
+ analyzer = FrameAnalyzer(camera_id="val_camera_1", violence_stride=8)
29
+
30
+ processed_frames = []
31
+ latencies = []
32
+ all_alerts = []
33
+
34
+ print("[Validation] Starting frame-by-frame analysis...")
35
+ start_total_time = time.time()
36
+
37
+ try:
38
+ for frame_idx, frame, timestamp in processor.extract_frames_generator(video_path):
39
+ t0 = time.time()
40
+ annotated, alerts, frame_meta = analyzer.analyze(frame, fps=10.0, output_base64=False)
41
+ t_elapsed = (time.time() - t0) * 1000.0
42
+
43
+ latencies.append(t_elapsed)
44
+ processed_frames.append(annotated)
45
+
46
+ for alert in alerts:
47
+ all_alerts.append((frame_idx, alert))
48
+
49
+ # Print frame summary every 10 processed frames
50
+ if len(processed_frames) % 10 == 0:
51
+ print(
52
+ f"Frame {frame_idx:04d} ({timestamp:.2f}s) | "
53
+ f"Latency: {t_elapsed:.1f}ms | "
54
+ f"People: {frame_meta['active_tracks_count']} | "
55
+ f"Threats: {frame_meta['threats_detected']}"
56
+ )
57
+ except KeyboardInterrupt:
58
+ print("[Validation] Interrupted by user. Compiling what we have...")
59
+
60
+ total_time = time.time() - start_total_time
61
+
62
+ if not latencies:
63
+ print("[Validation] No frames processed.")
64
+ sys.exit(1)
65
+
66
+ avg_latency = sum(latencies) / len(latencies)
67
+ throughput_fps = len(processed_frames) / total_time
68
+
69
+ print("\n--- Validation Statistics ---")
70
+ print(f"Frames Processed: {len(processed_frames)}")
71
+ print(f"Total Analysis Time: {total_time:.2f} seconds")
72
+ print(f"System Throughput: {throughput_fps:.2f} FPS")
73
+ print(f"Average Frame Latency: {avg_latency:.1f} ms")
74
+ print(f"Active Alerts Triggered: {len(all_alerts)}")
75
+
76
+ for f_idx, alert in all_alerts:
77
+ print(f" - Frame {f_idx}: [{alert.severity}] {alert.threat_type} ({alert.confidence*100:.1f}%)")
78
+
79
+ print(f"\n[Validation] Compiling output video to: {output_path}...")
80
+ processor.write_frames_to_video(
81
+ processed_frames,
82
+ output_path,
83
+ fps=10.0,
84
+ frame_size=(metadata['width'], metadata['height'])
85
+ )
86
+ print("[Validation] Finished successfully!")
87
+
88
+ if __name__ == "__main__":
89
+ main()