Spaces:
Running
Running
Commit ·
59bcdd1
0
Parent(s):
Initial commit: set up project skeleton and deploy workflows
Browse files- .github/workflows/deploy.yml +23 -0
- .gitignore +57 -0
- app.py +237 -0
- diagnose_thresholds.py +148 -0
- model/classes.json +6 -0
- model_results_comparison.md +51 -0
- read_docx.py +45 -0
- requirements.txt +20 -0
- suspicious_behavior/__init__.py +1 -0
- suspicious_behavior/alerts/__init__.py +1 -0
- suspicious_behavior/alerts/alert_manager.py +70 -0
- suspicious_behavior/alerts/alert_models.py +17 -0
- suspicious_behavior/api/__init__.py +1 -0
- suspicious_behavior/api/server.py +196 -0
- suspicious_behavior/config.py +68 -0
- suspicious_behavior/engines/__init__.py +1 -0
- suspicious_behavior/engines/running_engine.py +309 -0
- suspicious_behavior/engines/violence_engine.py +76 -0
- suspicious_behavior/pipeline/__init__.py +1 -0
- suspicious_behavior/pipeline/annotator.py +239 -0
- suspicious_behavior/pipeline/frame_analyzer.py +302 -0
- suspicious_behavior/pipeline/video_processor.py +88 -0
- suspicious_behavior/tracking/__init__.py +1 -0
- suspicious_behavior/tracking/sort_tracker.py +158 -0
- suspicious_behavior/tracking/track.py +131 -0
- testMOdel.py +77 -0
- tests/analyze_sample.py +166 -0
- tests/test_accuracy.py +233 -0
- tests/validate_system.py +89 -0
.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 |
+

|
| 13 |
+
<!-- slide -->
|
| 14 |
+

|
| 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 |
+

|
| 25 |
+
<!-- slide -->
|
| 26 |
+

|
| 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 |
+

|
| 37 |
+
<!-- slide -->
|
| 38 |
+

|
| 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 |
+

|
| 49 |
+
<!-- slide -->
|
| 50 |
+

|
| 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()
|