CS50P Final Project by Ivan Pavlović CS50 demo video: [PLACEHOLDER: add your CS50 submission video URL here]
A self-hostable video-intelligence platform with two ways in: connect a live camera (any RTSP source, including most home IP cameras) for continuous monitoring, or upload a video file for one-shot analysis. Either way it detects and tracks every person/vehicle, narrates what it sees in plain language as it happens, shows you exactly what it found (an annotated preview with detection boxes, track IDs, color, confidence, risk score), and raises explainable, scored incidents when a tracked object's behavior crosses a configured risk threshold, instead of a wall of raw bounding boxes. It is an AI inference and video-intelligence pipeline, not just an object detector: connect/upload, detect, track, narrate, alert, all running on CPU with no GPU requirement.
Both paths (live camera and uploaded video) share one detection, tracking,
scoring, and narration core (app/workers/tracking_pipeline.py), so a
camera watched live and a video analyzed after the fact are scored and
described identically; only how frames arrive differs.
- Problem it solves
- Features
- Technology stack
- Architecture
- Video processing pipeline, step by step
- Live cameras
- Risk algorithm
- The tracker
- Database schema
- Authentication & RBAC
- API reference
- Frontend
- Repository structure
- Environment variables
- Running with Docker Compose
- Local development without Docker
- Tests and the CS50P CLI
- Example API flow
- CS50P requirement mapping
- Known limitations
- Production hardening
- Future improvements
- Design decisions explained
- License
Home users and small physical-security teams running cameras (a doorbell or backyard camera at home, warehouses, retail back-of-house, construction sites) typically have two bad options: pay for an expensive cloud VMS/AI subscription, or have a human stare at a wall of video feeds. SentinelAI is a third option: a platform you run yourself, with no GPU requirement, that connects directly to a camera you already own and answers concrete questions:
- Who or what is in front of this camera right now?
- Did a person enter a restricted area?
- Did a vehicle remain in a zone longer than allowed?
- Did an object show abnormal movement: sudden direction reversals, bursts of speed, or other suspicious behavior?
- Which cameras and zones generate the most incidents?
- How long did inference take, what FPS was achieved, how long did jobs wait in the queue?
- Upload-based ingestion with asynchronous background processing (Celery); uploading a video takes you straight into a live analysis view.
- Multi-object tracking via a custom, lightweight centroid/IoU tracker: no heavy third-party re-identification model to download or maintain.
- Annotated AI preview: the source video is re-rendered with detection boxes, track IDs, confidence and Risk burned in frame by frame, so the preview shows exactly what the AI found, not just a status bar.
- Real-time detection reporting over WebSockets: running object-class counts stream in while the job is still processing, not only once it completes.
- Risk: an original, explainable 0–100 risk-scoring algorithm that fuses zone severity, dwell time, motion anomaly, time-of-day, and detection-confidence persistence, with exponential smoothing and hysteresis against noisy duplicate alerts.
- Automatic incident creation with evidence frames, a resolve/dismiss workflow with analyst notes, and a full audit trail.
- Delete a video (and everything derived from it: jobs, tracks, observations, incidents, evidence, the annotated preview file) in one action.
- A role-based dashboard (admin/analyst/viewer): video library, incidents table with filters, performance charts, and an admin-only audit log: styled as a dark security-operations console.
- Full JWT authentication (short-lived access tokens + rotating, revocable refresh tokens) with role-based access control.
| Layer | Technologies |
|---|---|
| Backend | Python 3.12, FastAPI, SQLAlchemy 2.0, Alembic, Pydantic v2 / pydantic-settings |
| Auth | JWT (access + refresh), passlib[bcrypt], per-route RBAC dependencies |
| Datastore / queue | PostgreSQL 16, Redis 7 |
| Background processing | Celery (Redis broker + result backend), concurrent.futures.ProcessPoolExecutor |
| Computer vision | OpenCV (headless), Ultralytics YOLOv8 (CPU) + a dedicated weapon-detection model, NumPy, psutil |
| Testing | pytest, httpx |
| Frontend | React 18, Vite, TypeScript, Tailwind CSS, React Router, TanStack Query, Recharts |
| Infrastructure | Docker, Docker Compose, named volumes, health checks |
frontend (React + Vite + TS + Tailwind)
│ HTTP (REST) + WebSocket
▼
backend (FastAPI)
routers/ → services/ → models/ (SQLAlchemy ORM)
│ reads/writes │ enqueues Celery task
▼ ▼
PostgreSQL Redis (Celery broker + result backend)
│
▼
celery_worker (same image as backend)
- detection.py: YOLOv8 + weapon-model inference
- tracker.py: centroid/IoU tracking
- tracking_pipeline.py: Risk scoring,
zone/rule evaluation, narration, incidents
- two entry points sharing the above:
process_video_task (uploaded file,
ProcessPoolExecutor
over fixed chunks)
process_live_camera_task (RTSP/webcam stream,
frame by frame,
real time)
- writes observations/events/incidents/metrics
The backend uses a strict service-layer architecture: routers/ only
handle HTTP in/out, services/ hold business logic, models/ are the
SQLAlchemy ORM classes, schemas/ are Pydantic validation layers,
algorithms/ are pure functions with no database dependency (Risk, the
tracker), workers/ are the Celery tasks, and websocket/ manages live
connections. Route handlers never contain business logic directly: they
always delegate to the matching service.
Key architectural decision: backend/app/algorithms/risk.py does
not re-implement the scoring algorithm: it imports the exact same functions
from the repository-root project.py (the CS50P module) via a sys.path
insert, so the CS50-graded module and the production pipeline share a single
source of truth.
- A user with the
analystoradminrole uploads a video through the Video Library page, or directly viaPOST /api/videos/upload. - The backend validates the file extension/MIME type, generates a safe
filename (UUID-based), stores it under
uploads/, creates avideo_sourcesrow and aprocessing_jobsrow (statusqueued), and dispatches a Celery task. - The worker opens the file with
cv2.VideoCaptureand reads its FPS, width, height and duration, persisting them onvideo_sources. - The video's duration is split into chunks with
split_video_chunks()(e.g. a 130s video with 30s chunks → 5 chunks, the last one 10s), and frame sampling + YOLO inference for each chunk are distributed across aProcessPoolExecutor: real parallelism across CPU cores. - YOLOv8 (CPU,
yolov8n.pt) detectsperson,car,truck,motorcycle, andbus. There is no synthetic fallback: if the model weights can't be loaded (an offline environment, a badYOLO_MODEL_PATH), the job fails with that error instead of substituting fabricated detections -- a result this pipeline produces is always something the model actually saw. - Detections from every sampled frame are replayed in timestamp order
through a
CentroidIoUTracker, which assigns each object a persistent track ID for as long as it stays visible. - Every observation (centroid, bounding box, confidence, speed, direction,
whether it's inside a zone) is written to
object_observations. - For each track, the active zones and rules are evaluated and a Risk score is computed (see below).
- Once the hysteresis condition is satisfied, an
incidentsrow is created along withincident_evidence(an annotated frame saved touploads/evidence/). - Job progress (
progress_percent) is updated in the database; the/ws/jobs/{id}endpoint polls it and pushes changes to connected clients over WebSocket. - Finally,
performance_metricsare recorded: total duration, frames processed, effective FPS, average and p95 inference latency (via NumPy percentile), CPU/RAM usage (viapsutil), queue wait time, and the number of incidents produced.
Connecting a camera is the primary way to use SentinelAI: add it under
Cameras with a name and its RTSP stream URL, test the connection, then
press Start. Most home IP cameras (Reolink, TP-Link Tapo, Hikvision, Wyze
with RTSP firmware, and similar) expose an RTSP URL in the form
rtsp://user:password@camera-ip:554/stream1, usually listed in the
camera's own app under network or advanced settings. Any source
cv2.VideoCapture can open works, including a plain video file path, which
is how the live pipeline is tested in this repository without needing
physical camera hardware.
Starting monitoring queues process_live_camera_task(job_id)
(app/workers/tasks.py), which:
- Opens the stream and reads one frame at a time, in real time (no
chunking: there is no fixed duration to split, unlike an uploaded file).
On a read failure, it reopens the capture and retries once before
giving up on the session: this handles both a flaky RTSP reconnect and
-- just as commonly in practice -- a snapshot-style public webcam that
serves one refreshed JPEG per HTTP request rather than a continuous
stream, where
cv2.VideoCapturelegitimately reports EOF after that one frame (see "Known limitations" andapp/seed.py's bundled demo cameras, all of which are this kind of source). - Samples every
FRAME_SAMPLE_RATE-th frame for detection, exactly like the file-upload path (both callapp.workers.detection.detect_frame), but skips re-running detection on a frame that's byte-identical to the last one it processed. A snapshot-style source can otherwise return the exact same image on every poll between its own refreshes; without this check, the tracker would see "the same object, still there" on every poll and keep accumulatingdwell_secondsagainst real wall-clock time, inflating Risk's dwell term for an object that, as far as this camera can actually tell, was only ever photographed once. - Feeds detections through the same
CentroidIoUTrackerand the samehandle_tracked_detectionfunction the file pipeline uses (app/workers/tracking_pipeline.py), so Risk scoring, the live activity narration, and incident creation work identically to an uploaded video. - Continuously overwrites a snapshot JPEG
(
uploads/live/camera_{id}_latest.jpg) with the current frame and its detection boxes burned in, served atGET /api/cameras/{id}/snapshot. This is a frequently refreshed still image, not a live video stream: a real low-latency video stream to the browser would need WebRTC or HLS, a much larger addition than this project's scope calls for, and a snapshot refreshed roughly once per sampled frame is enough to see what the camera sees and confirm detection is working. - Runs for up to
LIVE_SESSION_MAX_SECONDS(default 300s), then, if the camera is still marked active and no stop was requested, re-queues itself for another session rather than looping forever inside one Celery task. This keeps worker slots healthy and lets Celery's own restart/retry behavior apply normally. - Stops cleanly when
POST /api/cameras/{id}/stopsetsprocessing_jobs.stop_requested, which the loop polls for every ~2 seconds.
The Camera Detail page shows the live snapshot (auto-refreshing, with an "updated Xs/m/h ago" freshness indicator so a slow-refreshing public webcam reads as current rather than frozen), the click-to-draw zone editor, the same live activity log component as Video Detail, and Start/Stop controls.
No camera is ever started automatically -- Camera.is_active
defaults to False and only becomes True via an explicit
POST /api/cameras/{id}/start; adding a camera (including the demo ones
app/seed.py creates) never begins monitoring it by itself.
app/seed.py bundles six real, individually-verified public webcams in
Bosnia and Herzegovina as demo cameras (five Bergfex-hosted ski-resort
cams -- Jahorina, Ravna Planina, Bjelašnica ×2, Igman -- plus a BIHAMK
traffic camera in Banja Luka) instead of a synthetic/fabricated source, so
the live-camera path can be exercised end to end without needing physical
camera hardware or inventing fake footage. Each one was confirmed
reachable and showing a real current image before being added, not just
assumed to work from its URL.
An original, fully explainable risk-scoring algorithm implemented as pure
functions in project.py (shared with the backend), with no randomness
anywhere: every score is derived from real observation history, combining
zone severity, dwell time, motion anomaly, time-of-day, and detection
confidence into one 0–100 score.
raw_risk = 100 × (0.35·Z + 0.25·D + 0.20·M + 0.10·T + 0.10·C)
smoothed_risk = alpha × raw_risk + (1 − alpha) × previous_risk (alpha = 0.45)
| Component | Meaning | Formula |
|---|---|---|
| Z: zone severity | 0 outside any zone | low/medium/high/critical → 0.25/0.50/0.75/1.00 |
| D: dwell score | time spent in the zone vs. the rule's minimum | min(1, dwell_seconds / minimum_dwell_seconds) |
| M: motion anomaly | direction reversals and sudden speed bursts | see calculate_motion_anomaly() |
| T: temporal score | outside business hours / after_hours_only rule |
1.0 or 0.0 |
| C: confidence persistence | how consistently high-confidence recent detections are | derived from the last up-to-5 detections |
calculate_motion_anomaly() looks at consecutive velocity vectors for two
signals: a direction reversal (the cosine of the angle between two
consecutive velocity vectors is negative) and a speed burst (the ratio
between consecutive step speeds is ≥ 2.5×). The raw anomaly count is
normalized into [0, 1].
is_incident_triggered() implements hysteresis: an incident is only
raised once the last N consecutive evaluations clear the rule's
threshold, so a single noisy spike can never create a (false) incident on
its own. A second hysteresis rule prevents re-raising an incident for the
same track/rule pair until the score has stayed below threshold − 20 for
5 consecutive observations.
Final priority is derived from the smoothed score: 0–39 low, 40–59 medium, 60–79 high, 80–100 critical. Every incident also gets a
human-readable explanation, e.g.: "Person track #14 entered the High
Security Warehouse zone, remained for 22 seconds, showed two direction
reversals, and reached a Risk score of 84.3."
The algorithm is fully unit tested: test_project.py contains 17 pytest
tests covering boundary conditions, invalid inputs, hysteresis behaviour,
static movement, direction reversals, and non-evenly-divisible video
durations.
Rather than a heavy third-party solution (DeepSORT, ByteTrack + a
re-identification network), SentinelAI implements its own lightweight
CentroidIoUTracker in backend/app/algorithms/tracker.py. On every sampled
frame, new detections are matched against existing tracks using a
combination of bounding-box overlap (IoU) and centroid distance; unmatched
detections start a new track, and a track is retired after being
unobserved for a configurable number of consecutive frames. This is fast
enough on CPU at the frame-sampling rates the pipeline uses, and: just as
importantly: simple enough to reason about and unit test, which matters
more here than chasing state-of-the-art multi-object-tracking accuracy.
The PostgreSQL schema is defined through SQLAlchemy 2.0 models
(backend/app/models/) and a chain of Alembic migrations
(backend/alembic/versions/, currently 0001 through 0005), with
foreign keys, indexes on commonly filtered columns, and UTC timestamps
throughout.
| Table | Purpose | Notable fields |
|---|---|---|
users |
Accounts, roles, hashed passwords | email, role (admin/analyst/viewer), hashed_password, is_active |
refresh_tokens |
Revocable refresh tokens | user_id, token_hash, expires_at, revoked_at |
audit_logs |
Trail of important actions | user_id, action, resource_type, resource_id, details |
cameras |
Camera registry (live RTSP sources) | name, location, rtsp_url, owner_id, is_active |
video_sources |
Uploaded video files | original_filename, stored_filename, duration_seconds, fps, width, height |
processing_jobs |
One analysis run, either an uploaded video or a live-camera session | video_source_id or camera_id (exactly one set), is_live, stop_requested, status, progress_percent |
detection_models |
Registered detection models | name, version, weights_path, is_stub |
zones |
Polygon security zones per camera | severity, polygon_json (normalized [x, y] points), minimum_dwell_seconds |
rules |
Rules scoped to a zone | object_classes, after_hours_only, risk_threshold, consecutive_required |
tracked_objects |
One track within one processing job | track_number, object_class, dominant_color, first_seen_at, last_seen_at, final_risk_score |
object_observations |
Every single observation of a track | frame_number, centroid_x/y, bbox_x1/y1/x2/y2, confidence, zone_id, risk_score |
activity_events |
Live narration log entries | event_type (sighting/movement/anomaly/escalation/weapon/status), severity, description, risk_score |
incidents |
Created incidents | risk_score, status, priority, summary, resolution_notes, resolved_by/at |
incident_evidence |
Annotated evidence frames | frame_path, frame_number, timestamp_seconds |
performance_metrics |
Per-job performance data | processing_fps, avg_latency_ms, p95_latency_ms, avg_cpu_percent, avg_ram_mb, queue_wait_seconds |
Passwords are hashed with passlib[bcrypt]: never stored in plain text.
Login returns a short-lived JWT access token and a rotating refresh token
that is persisted (and revocable) in refresh_tokens, so logout is a real
server-side action, not just discarding a token on the client.
| Role | Permissions |
|---|---|
admin |
Full control: users, all cameras/zones/rules, all incidents, audit log |
analyst |
Upload video, create cameras/zones/rules, resolve incidents |
viewer |
Read-only: video, processing progress, incidents, reports |
Every protected route uses a require_roles(...) FastAPI dependency.
Important actions: login, upload, zone creation, rule changes, incident
status changes, deletions: are automatically written to audit_logs.
POST /api/auth/register
POST /api/auth/login
POST /api/auth/refresh
POST /api/auth/logout
GET /api/auth/me
GET /api/cameras
POST /api/cameras
PATCH /api/cameras/{id}
DELETE /api/cameras/{id} (also removes its jobs/tracks/incidents/zones/rules)
POST /api/cameras/test-connection (tries opening a stream URL before saving it)
POST /api/cameras/{id}/start (begins a live monitoring session)
POST /api/cameras/{id}/stop (requests the running session to stop cleanly)
GET /api/cameras/{id}/jobs (every monitoring session ever run for this camera)
GET /api/cameras/{id}/snapshot (latest frame with detection boxes, refreshed live)
GET /api/videos
POST /api/videos/upload
GET /api/videos/{id}
GET /api/videos/{id}/stream (original upload, HTTP Range-capable, for <video src>)
POST /api/videos/{id}/process
DELETE /api/videos/{id} (also removes its jobs/tracks/incidents/evidence/annotated files)
GET /api/jobs/{id}
GET /api/jobs/{id}/metrics
GET /api/jobs/{id}/detections (live, works mid-processing: see below)
GET /api/jobs/{id}/events (live activity narration log: see below)
GET /api/jobs/{id}/annotated-video (detection-overlay preview, HTTP Range-capable)
GET /api/videos/{id}/jobs (every job ever run for a video, newest first)
GET /api/incidents (paginated; filters: status, priority, date, camera, object_class)
GET /api/incidents/{id}
PATCH /api/incidents/{id}/status
GET /api/dashboard/summary
GET /api/dashboard/performance
GET /api/dashboard/analytics (incidents by hour/day/camera/class, ?days=)
GET /api/audit-logs (admin-only, paginated)
WS /ws/jobs/{job_id} (live processing progress)
WS /ws/incidents (live incident feed)
Full interactive OpenAPI/Swagger docs are served at
http://localhost:8000/docs whenever the backend is running.
A black/white/gray "security operations" theme (no accent hue: hierarchy
comes from brightness and weight), built with React, Vite, TypeScript and
Tailwind. Pages: Login / Register, Dashboard (camera status chips, stat
tiles, incidents-by-priority chart, recent incidents), Cameras (add/remove,
test connection, start/stop monitoring, never started automatically --
every camera sits idle until you explicitly press Start), Camera Detail
(live snapshot with an "updated Xs ago" freshness indicator, click-to-draw
zone editor with severity/dwell/rule-class/threshold controls, live
activity log, linked incidents scoped to that camera), Video Library
(upload + processing status + delete), Video Detail (annotated preview,
detections panel, linked incidents scoped to that video's job, delete),
Incidents (filterable table with a resolve/dismiss workflow and an alert-
delivery column), Analytics (incidents by hour-of-day and daily trend,
broken down by camera and object class -- real aggregate queries, not
anything computed client-side), Performance (latency/FPS/queue/CPU/RAM
charts via Recharts), and an admin-only Audit Logs page. Data is fetched
with TanStack Query, navigation uses React Router, and job progress/incidents
arrive live through a WebSocket hook. Zone/rule management (the polygons
and thresholds behind Risk's zone-severity term) is drawn directly on a
camera's live view (see Camera Detail above) rather than a separate
page; the underlying /api/zones//api/rules CRUD endpoints remain
usable directly too.
Every page is route-level code-split (React.lazy + a single Suspense
boundary in App.tsx): a production build used to ship one ~722KB
bundle, since every page's code -- including Login/Register, which never
touch a chart -- loaded recharts (the single heaviest dependency, 373KB
on its own) up front regardless of which page was actually visited.
Splitting dropped the main bundle to 255KB (84.6KB gzipped), with
recharts isolated into its own chunk fetched only by the 3 pages
(Dashboard, Performance, Analytics) that use it.
A plain <video> element can't attach an Authorization header, so
GET /api/videos/{id}/stream and GET /api/jobs/{id}/annotated-video
accept the access token as a ?token= query parameter as well (see
get_current_user_for_streaming); the frontend's authenticatedStreamUrl()
helper builds these URLs. Both endpoints implement real HTTP Range support
(206 Partial Content) so the browser can seek/scrub rather than downloading
the whole file up front. The Video Detail page shows the raw upload while a
job is pending/processing, then automatically switches to the annotated
preview: detection boxes, track ID, confidence and Risk score drawn
directly onto the footage: once rendering finishes (_render_annotated_video
in tasks.py; boxes persist for FRAME_SAMPLE_RATE frames past their last
sampled detection so playback doesn't flicker between sampled frames).
Uploading a video takes you straight to its Video Detail page, which
subscribes to /ws/jobs/{id} immediately: no need to trigger reprocessing
first. While the job is still running, every WebSocket tick carries the
running count of detected object classes (class_counts,
total_tracked_objects), not just a percentage: tracked_objects and
object_observations rows are committed to the database incrementally as
the pipeline runs, so "what's in this footage" becomes visible within
seconds of upload rather than only after the whole job finishes. This works
independently of whether the video's camera has any zone/rule
configured: detection and tracking always run; zone/incident evaluation
is the separate, optional layer on top (see Known limitations).
Once a job completes, GET /api/jobs/{id}/detections additionally returns
full per-track detail (class, confidence, observation count, final
Risk score).
Alongside the raw class counts, the pipeline generates a human-readable
narration stream (app/workers/events.py, the ActivityEventGenerator
class) as it tracks each object, written to the activity_events table
and rendered on the right-hand panel of the Video Detail page. Descriptions
aren't just "person track #2": every line is built from real visual
attributes extracted per detection (app/workers/visual_attributes.py):
- Color: the dominant color of each bounding-box crop, classified from the median HSV of a downsampled patch (robust to lighting/shadow; far cheaper than clustering). A track's reported color is the mode across its whole history, not a single noisy frame. "A red car..."
- Position: frame-relative thirds, the way a person points at a screen: "...in the right side of the frame", "...in the top-left of the frame", "...in the center of the frame".
- Movement: direction and qualitative speed from the track's recent centroid history: "moving quickly left", "stationary", "moving slowly downward, toward the bottom of the frame".
One ActivityEventGenerator instance runs per job, fed every observation
in temporal order, and emits a new line only when something is actually
worth saying:
- Sighting: the very first observation of a new track, a full scene description: "A red car (#3) appeared in the right side of the frame."
- Movement: only on an actual change of direction (not every tick, or this would be as noisy as raw coordinates): "A red car (#3) is now moving quickly left in the center of the frame."
- Anomaly: the first observation where a track's motion-anomaly score
(direction reversals / speed bursts, from
calculate_motion_anomaly) crossesANOMALY_EVENT_THRESHOLD(default 0.5): "A gray person (#2) is moving erratically in the left side of the frame (anomaly score 1.00): possible suspicious behavior." - Escalation: whenever a track's Risk severity bucket increases (low → medium → high → critical): "...risk escalated to MEDIUM (Risk 40.1), moving right in the center of the frame."
- Weapon: any detection whose class name matches
WEAPON_CLASS_NAMES(case-insensitive; defaultknife,gun,pistol,rifle,weapon,firearm,grenade,missile), alwayscriticalseverity, flagged once per track: "Weapon-like object detected on track #4 (knife)." - Status: a heartbeat line at most every
EVENT_HEARTBEAT_SECONDS(default 3s) per track when nothing else fired, so the log reads as a continuous narration rather than only alerts: "...continues moving normally" at low severity, "...remains at elevated risk" otherwise.
The same per-track dominant color is also persisted on tracked_objects
(shown in the Video Detail per-track table) and burned into the annotated
preview video's overlay labels, so the color mentioned in the narration is
the same one visible on screen.
Distinguishing multiple people: every person in frame gets its own
track ID, dominant clothing color, on-screen position, and movement
direction, scored and narrated independently (app/algorithms/tracker.py's
IoU+centroid matching keeps IDs stable across frames). When several people
are visible at the same time, the first sighting of each one also states
the concurrent count, e.g. "A red person (#3) appeared in the left side
of the frame. 3 people are in view at once, each tracked separately by ID,
color, and position." -- so the log and the per-track table both read as
distinct individuals, not one merged blob.
Every event is persisted with its severity, timestamp, and the risk score
that produced it (GET /api/jobs/{id}/events replays the full log for a
completed job); the WebSocket carries only the newest ~15 at a time while
a job is processing, and the frontend accumulates/dedupes them client-side
so the panel never loses earlier lines.
On weapon detection specifically: the bundled yolov8n.pt is trained
on COCO, whose 80 classes do not include any weapon category, so a
second, dedicated weapon-detection model (WEAPON_MODEL_REPO_ID /
WEAPON_MODEL_FILENAME / WEAPON_MODEL_REPO_TYPE, default
Dricz/Weapon_Detection_YOLOv8-4, a 5-class YOLOv8 checkpoint covering
grenade/knife/missile/pistol/rifle) runs alongside the main model on every
sampled frame, for both uploaded video and live cameras
(app/workers/detection.py: load_weapon_model / detect_weapons). Its
detections are normalized into WEAPON_CLASS_NAMES and flow through the
exact same tracking/risk/incident/narration path as any other detection --
including Risk scoring, dwell time, zone matching, and the activity
log. It's downloaded once and cached locally (see the model_cache volume
in docker-compose.yml); if it can't be fetched (no internet, gated
repo), weapon detection is simply skipped for that session; set
WEAPON_MODEL_ENABLED=false to disable it outright. Swap in a different
WEAPON_MODEL_REPO_ID/WEAPON_MODEL_FILENAME to extend or replace
coverage; no other code changes needed.
Batons/sticks are not covered: as of this writing there is no pretrained open-source checkpoint for baton/stick detection (checked Hugging Face and Roboflow Universe), unlike guns/knives/grenades, which several community YOLOv8 checkpoints already cover well. A baton swung as a weapon still surfaces indirectly via the Anomaly event (erratic/ sudden motion) and Risk's motion-anomaly term, just not as its own named weapon class, until such a model is trained or found.
sentinelai/
├── project.py # CS50P: Risk algorithm + CLI (stdlib only)
├── test_project.py # 17 pytest tests
├── requirements.txt
├── README.md
├── .env.example
├── docker-compose.yml
├── backend/
│ ├── app/
│ │ ├── main.py
│ │ ├── core/ # config, security (JWT/bcrypt), logging
│ │ ├── db/ # SQLAlchemy engine/session
│ │ ├── models/ # 15 tables
│ │ ├── schemas/ # Pydantic schemas
│ │ ├── routers/ # FastAPI route handlers (cameras, videos, jobs, incidents...)
│ │ ├── services/ # business logic (camera_service, video_service, job_service...)
│ │ ├── algorithms/ # Risk import + tracker.py
│ │ ├── workers/ # celery_app.py, tasks.py (Celery entry points),
│ │ │ # detection.py, tracking_pipeline.py, annotate.py,
│ │ │ # events.py, visual_attributes.py (shared pipeline core)
│ │ ├── websocket/ # connection manager
│ │ └── seed.py # demo data
│ └── alembic/ # migrations
├── frontend/
│ ├── src/ # React pages, hooks, API client
│ └── package.json
├── uploads/ # uploaded video, evidence frames, annotated previews,
│ │ # and live/ (per-camera snapshot JPEGs)
└── docs/ # extended HTML project documentation
All variables live in .env.example (copy to .env). Key ones:
| Variable | Purpose | Default |
|---|---|---|
DATABASE_URL |
PostgreSQL connection string | postgresql+psycopg2://sentinel:sentinel@postgres:5432/sentinel |
JWT_SECRET |
Signing key for JWTs: change in production | : |
ACCESS_TOKEN_EXPIRE_MINUTES / REFRESH_TOKEN_EXPIRE_DAYS |
Token lifetimes | 30 / 7 |
REDIS_URL, CELERY_BROKER_URL, CELERY_RESULT_BACKEND |
Redis connections for Celery | redis://redis:6379/0,1 |
CORS_ORIGINS |
Allowed frontend origins (http://localhost with no port is required for the nginx entrypoint on :80 -- see "Production hardening") |
http://localhost:5173,http://localhost:3000,http://localhost |
UPLOAD_DIR, MAX_UPLOAD_SIZE_MB, ALLOWED_VIDEO_EXTENSIONS |
Upload validation: MAX_UPLOAD_SIZE_MB=0 means unlimited |
/app/uploads, 0, .mp4,.avi,.mov,.mkv,.ts,.mxf,.webm |
DB_POOL_SIZE, DB_MAX_OVERFLOW, DB_POOL_TIMEOUT_SECONDS, DB_POOL_RECYCLE_SECONDS |
PostgreSQL connection pool sizing | 20, 40, 30, 1800 |
CELERY_WORKER_CONCURRENCY |
Parallel worker threads per celery_worker container |
8 |
CELERY_TASK_SOFT_TIME_LIMIT_SECONDS / CELERY_TASK_TIME_LIMIT_SECONDS |
Ceiling for a single video-processing job | 21600 (6h) / 23400 (6.5h) |
UVICORN_WORKERS |
API process count (separate from Celery concurrency) | 2 |
DEFAULT_CHUNK_SECONDS, FRAME_SAMPLE_RATE |
Pipeline tuning | 30, 5 |
LIVE_SESSION_MAX_SECONDS |
Max length of one live-camera task invocation before re-queuing | 300 |
YOLO_MODEL_PATH |
YOLO weights (the job fails cleanly if these can't be loaded -- no synthetic fallback) | yolov8n.pt |
INCIDENT_RISK_THRESHOLD, INCIDENT_CONSECUTIVE_REQUIRED |
Default rule thresholds | 70.0, 3 |
VITE_API_BASE_URL, VITE_WS_BASE_URL |
Frontend → backend endpoints | http://localhost:8000, ws://localhost:8000 |
SentinelAI's defaults are tuned for a multi-camera, multi-operator deployment rather than a single local demo:
- Uploads are unbounded by default (
MAX_UPLOAD_SIZE_MB=0). The upload handler always streams straight to disk in 8MB chunks (video_service. save_upload): it never buffers a file fully in memory, so there's no practical ceiling besides the disk space backing theuploadsvolume. Raise/lower the cap per-deployment if you want one. - An
nginxreverse proxy (nginx/nginx.conf) sits in front of the stack on port 80 withclient_max_body_size 0(unlimited) and 3600s read/send timeouts, so a reverse proxy never becomes the thing that silently truncates a multi-gigabyte upload or a long-lived WebSocket connection: a very common production gotcha. Point real traffic at port 80 instead of hitting the backend/frontend ports directly.docker compose upalready starts it alongside the rest of the stack. - The PostgreSQL connection pool is sized for many concurrent
dashboard/API/WebSocket clients (
DB_POOL_SIZE=20,DB_MAX_OVERFLOW=40by default), not the handful of connections a single-user demo needs. - Celery runs with
--pool=threadsandCELERY_WORKER_CONCURRENCY=8parallel threads (not the default "prefork" process pool: each video- processing task itself spawns aProcessPoolExecutorto parallelize chunk/inference work, and CPython refuses to let a daemonic process -- which is what a prefork pool worker is -- spawn children of its own; threads don't have that restriction, so--max-tasks-per-child, a prefork-only flag, doesn't apply here and is intentionally not set). Each concurrently-monitored live camera occupies one thread for the life of its session (re-queuing into a fresh thread roughly everyLIVE_SESSION_MAX_SECONDS), soCELERY_WORKER_CONCURRENCYmust be >= the number of cameras you want actively monitored at once, plus headroom for upload-processing jobs, or the extra cameras just queue until a thread frees up.task_acks_late=Truemeans a job re-queues instead of being lost if a worker is killed mid-processing, and multi-hour soft/hard time limits keep long camera footage from being killed partway through. ScaleCELERY_WORKER_CONCURRENCYor add morecelery_workerreplicas to match your camera count. uvicornruns with multiple worker processes (UVICORN_WORKERS, default 2) and a 3600s keep-alive timeout for large uploads.
None of this requires a GPU or paid infrastructure: it's standard Docker Compose scaling knobs, all exposed as environment variables so a real deployment can be sized to its camera count and traffic without touching code.
cp .env.example .env # adjust JWT_SECRET etc. for anything beyond local demo use
docker compose up --buildThis starts 6 services: postgres, redis, backend (FastAPI, which runs
alembic upgrade head automatically before uvicorn starts),
celery_worker, frontend, and nginx: a reverse proxy in front of
everything with an unlimited request body size and long timeouts (see
Scaling for large deployments).
- Everything through the proxy: http://localhost (dashboard at
/, API at/api/*, docs at/docs, WebSockets at/ws/*) - Backend / Swagger docs directly: http://localhost:8000/docs
- Frontend dashboard directly: http://localhost:5173
Seed the database with demo data (works without processing a real video):
docker compose exec backend python -m app.seedDemo accounts: admin@sentinelai.dev / Admin123! and
analyst@sentinelai.dev / Analyst123!
# Backend
cd backend
python -m venv .venv && .venv\Scripts\activate # or source .venv/bin/activate on Linux/Mac
pip install -r ../requirements.txt
alembic upgrade head # requires a running Postgres, or a SQLite DATABASE_URL for a quick demo
python -m app.seed
uvicorn app.main:app --reload
# Celery worker (separate terminal, requires Redis)
celery -A app.workers.celery_app worker --loglevel=info
# Frontend
cd frontend
npm install
npm run dev # http://localhost:5173python -m pytest test_project.py -v
python project.py demo-risk
python project.py split-video --duration 130 --chunk-size 30
python project.py serveproject.py contains, alongside main(), four top-level functions:
calculate_risk, is_incident_triggered, calculate_motion_anomaly,
and split_video_chunks: all covered by the 17 pytest tests in
test_project.py (boundary cases, invalid inputs, hysteresis behaviour,
static movement, direction reversals, and a non-evenly-divisible video
duration).
curl -X POST localhost:8000/api/auth/login -H "Content-Type: application/json" \
-d '{"email":"admin@sentinelai.dev","password":"Admin123!"}'
# -> {"access_token": "...", "refresh_token": "..."}
curl -X POST localhost:8000/api/videos/upload -H "Authorization: Bearer <token>" \
-F "file=@dock_feed.mp4"
# -> creates a video_sources + processing_jobs row, dispatches the Celery task
curl localhost:8000/api/jobs/1
curl "localhost:8000/api/incidents?status=open&priority=high"
curl -X PATCH localhost:8000/api/incidents/1/status -H "Authorization: Bearer <token>" \
-d '{"status":"resolved","resolution_notes":"Verified with site security."}'| Requirement | Status |
|---|---|
project.py in the root, with main() + 3+ custom functions |
✅ 4 functions: calculate_risk, is_incident_triggered, calculate_motion_anomaly, split_video_chunks |
test_project.py with tests for 3+ functions |
✅ 17 tests, covering all four functions plus edge/invalid cases |
requirements.txt |
✅ in the repo root |
README.md, 500+ words |
✅ (this file) |
- No GPU is required: the baseline targets CPU inference. YOLOv8n is small enough to run on CPU at a reduced frame-sampling rate.
- If
ultralyticscannot download the YOLOv8 weights (an offline environment, a badYOLO_MODEL_PATH), the job fails with that error instead of continuing on fabricated data. This is deliberate: a result this pipeline produces should always be something the model actually saw, never a stand-in. Have the weights available (or cached locally) before running a job. - WebSocket progress uses per-connection database polling rather than Redis pub/sub: simple and reliable for a single backend replica, but not horizontally scalable as-is.
- Detection/tracking always runs on upload, but an uploaded video isn't
linked to a camera, so zone-based incident scoring (the
Zterm in Risk) stays at 0 for the dashboard-uploaded path regardless of what zones exist. Detections, tracking, confidence and motion-anomaly-based risk still work fully; the zone-severity component only applies to a camera's live-monitoring sessions, where zones are configured from the Camera Detail page's zone editor (orbackend/app/seed.pyfor demo data). - Annotated-preview rendering re-reads every frame of the source video once tracking finishes, so its cost scales with total video length, not just the sampled frames used for detection; for very long footage this adds meaningfully to total job time.
- Live-camera tracking state (track IDs, each track's risk history) is
persisted to Redis and restored across re-queued session chunks (see
"Production hardening" below), so this is no longer a hard reset. It is
still not a single unbroken process, though: if the Celery worker
restarts, or Redis itself is flushed/unavailable, between two chunks
(every
LIVE_SESSION_MAX_SECONDS, 300s by default), that session falls back to a fresh tracker and loses continuity for objects that were mid-track at that exact moment. A fully continuous tracker would need one long-lived worker process per camera instead of Celery's short-task model; the Redis-backed approach covers the common case (normal operation) without that architectural change. - A camera's live view is a frequently refreshed snapshot image, not a video stream; see "Live cameras" above for why.
- The default weapon-detection model
(
WEAPON_MODEL_REPO_ID=Dricz/Weapon_Detection_YOLOv8-4) covers grenade/knife/missile/pistol/rifle, but has no baton/stick class -- no pretrained open-source checkpoint for that exists as of this writing. PointWEAPON_MODEL_REPO_ID/WEAPON_MODEL_FILENAMEat a different model to extend or replace coverage; no other code changes needed. - Redis has no password and is published on the host
(
docker-compose.yml, port 6379, norequirepass). The code-execution path this previously enabled (an unauthenticated pickle payload) is fixed -- see "Security hardening" below -- but an unauthenticated Redis is still readable/writable/flushable by anyone who can reach that port, which affects the Celery broker/result backend and the live-tracker state key alike. Addrequirepass(and updateREDIS_URL/CELERY_BROKER_URL/CELERY_RESULT_BACKENDaccordingly) before exposing this stack beyond a trusted local network.
Beyond the core detection/tracking/scoring pipeline, these exist specifically to make the system safe and usable in a real deployment, not just a demo:
- Real-time alerting (
app/services/alert_service.py): any incident at or aboveALERT_MIN_PRIORITY(defaulthigh) is dispatched to email (SMTP, stdlibsmtplib) and/or a generic webhook (Slack/Discord/ PagerDuty/your own endpoint all accept a plain JSON POST) the moment it's created -- not just logged for someone to notice later. A per-(camera, object_class)cooldown (ALERT_COOLDOWN_SECONDS, default 120s) stops one lingering detection from paging someone every few seconds, and delivery is strictly best-effort: a bad SMTP password or an unreachable webhook is logged and never blocks or fails the detection pipeline. EachIncidentrecords whether an alert was actually attempted and which channels succeeded (alert_sent,alert_channels), shown as a column on the Incidents page.POST /api/alerts/test(admin-only) sends a synthetic critical alert through every configured channel so you can confirm delivery works before relying on it. - Weapon-person association (
app/workers/tracking_pipeline.pyassociate_weapons_to_people): a weapon-class track withinWEAPON_PERSON_ASSOCIATION_MAX_DISTANCE_RATIO(default 25%) of the frame diagonal of an active person track is treated as held by that person -- narrated by name ("Person #3 appears to be holding a knife (track #5)") and immediately raised as a CRITICAL incident with a real-time alert, independent of any zone/rule configuration. A weapon next to a person is inherently worth surfacing immediately, not gated behind dwell-time or a configured zone. - Live-camera tracker persistence (
app/workers/tracking_pipeline.pysave_live_tracking_state/load_live_tracking_state): track IDs, risk history, and narration state for a live-camera session are serialized as plain JSON (not pickle -- see "Security hardening" below) to Redis at the end of eachLIVE_SESSION_MAX_SECONDSchunk and restored by the next re-queued invocation, so a continuously-monitored camera keeps one stable set of track IDs for the life of the session instead of resetting every 300 seconds. Falls back to a fresh tracker if Redis is unreachable or the saved state can't be loaded, rather than failing the task; a TTL on the saved blob bounds how long stale state could ever linger if cleanup is somehow skipped (e.g. a worker crash mid-session). - Auth rate limiting (
app/core/rate_limit.py):/api/auth/loginand/api/auth/registerare rate-limited per client IP via a Redis-backed fixed-window counter (correct across multipleUVICORN_WORKERSprocesses, unlike an in-memory counter), to slow down credential- stuffing/brute-force attempts and registration spam. Fails open if Redis itself is unreachable, rather than locking every user out over an unrelated outage. - Fail-fast production config validation (
app/core/config.py_validate_production_safety): withENV=production, the app refuses to start at all ifJWT_SECRETis still the bundled dev default (or under 32 characters) or ifDEBUGis stilltrue-- a wrong-but-running deployment is worse than one that won't start. - Global exception handler (
app/main.py): any exception that escapes a route handler is always logged in full server-side, but the client only ever sees a generic "Internal server error" (unlessDEBUG=true), so a bug can't leak internal exception messages, stack traces, or query details to a caller. - nginx re-resolves its upstreams instead of caching them forever
(
nginx/nginx.conf): nginx's staticupstream {}block resolves a hostname likebackendonce, at startup, and keeps using that IP until nginx itself restarts. Recreating thebackend/frontendcontainer for any reason (a rebuild,docker compose up, a crash restart) gives it a new internal IP, which previously left nginx silently proxying to a dead address until someone noticed the 502s and restarted it by hand.resolver 127.0.0.11 valid=10s(Docker's embedded DNS) plus aset $upstream ...; proxy_pass $upstream;indirection instead of theupstream {}block makes nginx re-resolve every 10s, so it self-heals within that window with zero manual intervention. - CORS covers the nginx entrypoint, not just direct dev access: the
frontend's API base URL is an absolute
http://localhost:8000baked in at build time (VITE_API_BASE_URL), so even a page served through nginx on:80still calls the backend cross-origin from the browser's point of view.CORS_ORIGINSincludeshttp://localhost(no port) by default for exactly this reason -- without it, every API call made from the nginx-served page fails outright with noaccess-control-allow-originheader, even though the direct:5173dev path works fine.
A pass specifically looking for N+1 query patterns and redundant round-trips, verified by counting actual SQL statements against a live database rather than just reading the code:
- Dashboard summary: 13 queries → 5 (
app/services/dashboard_service.py): previously oneSELECT COUNT(*)per metric (cameras, active cameras, videos, each of 4 job statuses, open incidents, incidents-last-24h, and one more perIncidentPriorityin a Python loop). PostgresFILTER- qualified aggregates (func.count(...).filter(...)) compute every count for a table in one scan instead of rescanning it once per metric. - Incident list: N+1 on evidence (
app/services/incident_service.py):IncidentRead.evidenceis a relationship with no eager-loading strategy, so serializing a page of incidents triggered one extra lazy- load query per row.selectinload(Incident.evidence)replaces that with exactly one extra query for the whole page, confirmed to stay flat as row count grows (not proportional to it). - Video deletion: the same pattern, twice (
app/services/video_service.py): one incidents query per job being deleted, then one more per incident for its evidence files. Collapsed to a singleselectinloadquery across every job at once. - Camera list: N+1 against the API, not just the DB
(
Cameras.tsx/Dashboard.tsx): both fetched every camera's most recent job viaPromise.all, one HTTP request per camera, purely to render a status dot.GET /api/camerasnow returns each camera'slatest_job_statusinline, computed server-side with aROW_NUMBER() OVER (PARTITION BY camera_id ...)window query -- 2 queries total regardless of camera count or how many historical job rows each camera has accumulated (a live camera re-queues a new job row roughly everyLIVE_SESSION_MAX_SECONDS, so that history grows unbounded over a camera's lifetime; a naive "fetch all jobs, group in Python" fix would have scaled badly here). - Rules-per-zone: the same pattern on the Camera Detail page: fetched
rules one zone at a time.
GET /api/rulesgained an optionalcamera_idfilter (a join throughZone) so the page fetches every rule across all of a camera's zones in one request.
Not just slow -- CameraDetail.tsx and VideoDetail.tsx were showing the
wrong incidents: both queried /api/incidents with no scoping filter
at all, so each page displayed the 50 most recent incidents system-wide,
indistinguishable from incidents actually tied to that camera or video
once more than one of either existed. Fixed by wiring the already-
existing camera_id filter into CameraDetail.tsx, and adding a new
processing_job_id filter (app/routers/incidents.py) for
VideoDetail.tsx, since no equivalent existed for scoping by video/job.
Three issues found and fixed by a dedicated security-review pass over this project's own code (not general advice -- each one was a concrete, verified bug in a specific file, fixed and re-tested against the live stack before being considered done):
- SSRF in camera stream URLs (
app/services/camera_service.py_validate_stream_url):POST /api/cameras/test-connectionand the live-camera pipeline opened whatever URL was supplied viacv2.VideoCapturewith no validation at all -- any scheme, any host. An authenticated admin/analyst could point it at an internal-only target (http://169.254.169.254/latest/meta-data/, the cloud-metadata address every major provider uses, or the server's own loopback) and theok/width/height/fpsresponse would reveal what's reachable. Fixed with a scheme allowlist (rtsp(s),http(s),rtmp(s)) and a block on hostnames resolving to loopback or link-local addresses, applied everywhere a URL is accepted or reused (create, update, test, and again immediately before a live session actually connects). Deliberately does not block private LAN ranges (192.168.x.x/10.x.x.x): that's exactly where this product's real home/office IP cameras live (see "Live cameras" above), so blocking all private IPs would break the core use case, not just attacks. Verified against the standard SSRF bypass toolkit (decimal/hex/octal IP encoding, IPv4-mapped IPv6, case variation) with zero bypasses found, alongside confirming real camera URLs still pass. - Insecure deserialization in live-tracker Redis state
(
app/workers/tracking_pipeline.py):load_live_tracking_stateused to callpickle.loads()on whatever bytes were stored atsentinel:live_tracker_state:{job_id}in Redis. Redis is published on the host (docker-compose.yml, norequirepassconfigured -- see "Known limitations"), so anyone reachable on that port could write a malicious pickle payload to that key; the next time that job's session re-queued (routine, everyLIVE_SESSION_MAX_SECONDS), the worker would deserialize and execute it. Replaced with explicit JSON (de)serialization of only the known, simple fields each structure actually has -- this doesn't just mitigate the issue,json.loads()has no code-execution primitive at all, so the vulnerability class is gone regardless of who else can write to that key. Verified with a simulated malicious pickle payload that previously would have executed code; it now fails safely with a decode error and falls back to a fresh tracker. - Unhandled 500 on NaN/Infinity input (
app/main.py,app/schemas/zone.py): two distinct crash paths. Plain float fields with no custom validator (risk_threshold,minimum_dwell_seconds) silently acceptedInfinity/NaNas "valid" (Python'sfloatsupports them, and Pydantic doesn't reject them by default), which then reached Postgres and only crashed later when the success response tried to serialize it back (Starlette'sJSONResponseenforces strict, spec-compliant JSON) -- an unhandled 500 on a row that had already been persisted. Fixed withField(allow_inf_nan=False)so Pydantic itself rejects it before the handler runs. Separately, a field with a custom@field_validator(the zone polygon's[0,1]range check) already rejected the bad value correctly, but Pydantic v2 wraps a validator's raisedValueErrorasctx.errorholding the exception object, which plainjson.dumpscan't encode -- reporting the rejection crashed just as hard as accepting the bad value did elsewhere. Fixed with aRequestValidationErrorhandler that recursively sanitizes the error payload before it's serialized.
ONNX Runtime/TensorRT as an optional faster inference backend, an optional GPU Celery queue, distributed multi-node processing, semantic search over incident summaries (embeddings + a vector database), SMS notifications (Twilio or similar, alongside the email/webhook alerting already in place), and Redis pub/sub-backed WebSocket fan-out for multi-replica deployments.
Why a CPU baseline: the project should run on a laptop or a cheap VPS without CUDA. YOLOv8n is small enough to be CPU-viable at low frame-sampling rates, so no GPU is required for a working deployment.
Why a custom, lightweight tracker instead of a heavy third-party one (e.g. DeepSORT/ByteTrack + a re-identification model): the goal of this project is explainable zone/rule evaluation, not state-of-the-art multi-object tracking. IoU + centroid matching is simple to reason about, has no extra model weights to download, and is fast enough on CPU for the frame-sampling rates this pipeline actually uses.
Extended technical documentation (with additional diagrams and
component-by-component explanations) lives at
docs/sentinelai-dokumentacija.html.
MIT: see LICENSE if present, or treat this project as MIT-licensed for
educational/demonstration purposes.