Real-time retail analytics from CCTV footage. A computer-vision pipeline detects visitors, zone dwell, and billing-queue activity from store video; a FastAPI service ingests events and exposes metrics, funnels, heatmaps, and anomalies; a Streamlit dashboard visualizes live store performance.
Reference store: ST1008 (Brigade Bangalore) — see store-layout.json.
| Layer | Responsibility |
|---|---|
| Detection pipeline | YOLOv8 + ByteTrack on MP4 clips → JSONL event stream |
| Intelligence API | Idempotent event ingestion, session derivation, analytics endpoints |
| Dashboard | Polls the API for metrics, funnel, heatmap, queue, and anomalies |
Analytics surfaced
- Unique visitors, conversion rate, average dwell
- Entry → zone → billing → purchase funnel
- Zone heatmap (visit frequency, normalized 0–100)
- Billing queue depth and wait patterns
- Rule-based anomalies (queue spike, conversion drop, dead zones, stale feed)
flowchart LR
subgraph ingest [Ingestion]
CCTV[CCTV MP4 clips]
POS[POS CSV]
end
CCTV --> DET[pipeline/detect.py]
DET --> JSONL[output/events.jsonl]
JSONL --> EVT[pipeline/ingest_events.py]
POS --> POSING[pipeline/ingest_pos.py]
EVT --> API[FastAPI POST /events/ingest]
POSING --> API
API --> DB[(SQLite)]
DB --> EP[GET metrics / funnel / heatmap / anomalies / queue]
EP --> DASH[Streamlit dashboard :8501]
Design choices
- Events are the source of truth;
visitor_sessionsandqueue_eventsare derived on ingest. - Duplicate
event_idvalues return 409 Conflict (idempotent ingestion). - Zones use static pixel regions (
pipeline/zones.py+store-layout.json), not VLMs. - Staff are heuristically excluded (tracks visible for many sampled frames).
Full design notes: Desgin.MD · Engineering rationale: CHOICES.md
store-intelligence/
├── app/ # FastAPI application
│ ├── api/routes/ # HTTP endpoints
│ ├── core/ # Settings (pydantic-settings)
│ ├── db/ # SQLAlchemy models and database
│ ├── schemas/ # Pydantic request/response models
│ └── services/ # Business logic (metrics, funnel, anomalies, …)
├── pipeline/ # Offline CV pipeline and ingest scripts
│ ├── detect.py # Main orchestrator (python -m pipeline.detect)
│ ├── tracker.py # YOLO + ByteTrack wrapper
│ ├── zones.py # Zone polygons per camera
│ ├── emit.py # Event builders and JSONL merge
│ ├── ingest_events.py # POST JSONL to API
│ ├── ingest_pos.py # CSV → PURCHASE events
│ └── run.sh # detect → ingest events → ingest POS
├── dashboard/ # Streamlit UI (app.py)
├── tests/ # Pytest API tests
├── scripts/ # Utilities (e.g. seed_demo_data.py)
├── data/ # POS CSV and optional video inputs
├── Dataset/ # Default dataset root for pipeline
├── output/ # Pipeline JSONL output (created at runtime)
├── store-layout.json # Store, camera, and zone metadata
├── docker-compose.yml # API + dashboard services
├── Dockerfile # API image (includes ML deps)
├── Dockerfile.dashboard # Dashboard image
├── requirements*.txt # Python dependencies (split by layer)
├── setup.sh # Create venv and install deps on host
├── Desgin.MD # Architecture documentation
└── CHOICES.md # Engineering decision log
- Docker and Docker Compose (recommended for API + dashboard)
- Python 3.11+ on the host for running the detection pipeline (heavy ML deps; not required inside the API container for normal API-only use)
- curl (optional, for API examples)
git clone <repo-url> store-intelligence
cd store-intelligence
docker compose up --build| Service | URL |
|---|---|
| API | http://localhost:8000 |
| API docs (Swagger) | http://localhost:8000/docs |
| Dashboard | http://localhost:8501 |
| Health | http://localhost:8000/health |
The API persists data in a Docker volume (db_data) at sqlite:////app/db/store_intelligence.db.
The pipeline needs GPU/CPU ML dependencies on the host (or a machine with the project venv), not only inside the API container:
bash setup.sh # creates venv/ and installs requirements.txt
bash pipeline/run.sh # detect → ingest events → ingest POSsetup.sh installs requirements.txt (API + ML stacks). pipeline/run.sh uses venv/bin/python when present.
If you have no video clips, populate the API with sample events:
# API must be running
python scripts/seed_demo_data.py
# API_URL=http://localhost:8000 STORE_ID=ST1008 (defaults)On the host, bash pipeline/run.sh works without the API: ingest falls back to local SQLite (store_intelligence.db). To use the HTTP API (required for the Docker dashboard DB volume), start it first:
docker compose up --buildThen on the host:
bash setup.sh
bash pipeline/run.shOr step by step:
python -m pipeline.detect --dataset Dataset --output output
python pipeline/ingest_events.py output/events.jsonl
python pipeline/ingest_pos.py data/Brigade_Bangalore.csvpipeline/run.sh defaults: DATASET_DIR=Dataset, OUTPUT_DIR=output. Override with environment variables:
DATASET_DIR=data/videos OUTPUT_DIR=output bash pipeline/run.sh
PYTHON_BIN=.venv/bin/python bash pipeline/run.shPlace .mp4 files under a dataset root. The pipeline recursively discovers clips whose filename contains one of:
| Substring in filename | Role | Typical use |
|---|---|---|
entry |
ENTRY | Door threshold (CAM 3) |
zone |
ZONE | Floor cameras — dwell by zone |
billing |
BILLING | Cash counter queue (CAM 5) |
Store folders: paths like Store 1/ or Store 2/ map to store IDs (ST1076, ST1008). Other folder names are normalized to ST#### via pattern matching or hashing.
Example layout (bundled dataset style):
Dataset/
├── Store 1/
│ ├── entry_cam3.mp4
│ ├── zone_floor1.mp4
│ └── billing_cam5.mp4
└── Store 2/
└── ...
Alternative layout (README-style naming):
data/videos/
├── CAM 1.mp4 # Floor — zone dwell
├── CAM 2.mp4
├── CAM 3.mp4 # Entry/exit (filename must include entry/zone/billing role keyword)
├── CAM 4.mp4
└── CAM 5.mp4 # Billing
Clips must include
entry,zone, orbillingin the filename for discovery. Rename or symlink if your files use onlyCAM N.mp4naming.
Place transaction CSV at one of:
data/Brigade_Bangalore.csv(preferred byrun.sh)data/pos_transactions.csv
Sample POS files also exist under Dataset/.
| Variable | Default | Description |
|---|---|---|
VID_STRIDE |
6 |
Process every Nth frame (~6× faster) |
MAX_FRAMES |
0 |
Cap frames per clip (0 = unlimited) |
YOLO_MODEL |
yolov8s.pt |
Ultralytics weights (see pipeline/tracker.py) |
IMG_SIZE |
320 |
Inference image size |
API_URL |
http://localhost:8000 |
Target for ingest_events.py and ingest_pos.py |
DATASET_DIR |
Dataset |
Used by run.sh |
OUTPUT_DIR |
output |
JSONL output directory |
Output: per-camera events_*.jsonl and merged output/events.jsonl.
ENTRY, EXIT, ZONE_ENTER, ZONE_EXIT, ZONE_DWELL, BILLING_QUEUE_JOIN, BILLING_QUEUE_ABANDON, REENTRY, PURCHASE
| Method | Path | Description |
|---|---|---|
GET |
/ |
Service version |
GET |
/health |
DB status, last event time, stale_feed if no event in 10 minutes |
| Method | Path | Description |
|---|---|---|
POST |
/events/ingest |
Ingest one event (409 if event_id already exists) |
| Method | Path | Description |
|---|---|---|
GET |
/stores/ |
List stores with recent activity |
GET |
/stores/{store_id} |
Store metadata from events |
| Method | Path | Description |
|---|---|---|
GET |
/stores/{store_id}/metrics |
Visitors, conversion, dwell |
GET |
/stores/{store_id}/funnel |
Entry → zone → billing → purchase |
GET |
/stores/{store_id}/heatmap |
Zone visit frequency (0–100) |
GET |
/stores/{store_id}/anomalies |
Active anomalies with severity |
GET |
/stores/{store_id}/queue |
Billing queue statistics |
curl http://localhost:8000/health
curl http://localhost:8000/stores/ST1008/metrics
curl http://localhost:8000/stores/ST1008/funnelIngest an event:
curl -X POST http://localhost:8000/events/ingest \
-H "Content-Type: application/json" \
-d '{
"event_id": "550e8400-e29b-41d4-a716-446655440000",
"store_id": "ST1008",
"camera_id": "CAM_3",
"visitor_id": "VIS_abc123",
"event_type": "ENTRY",
"timestamp": "2026-04-10T16:00:00Z",
"dwell_ms": 0,
"is_staff": false,
"confidence": 0.91,
"metadata": {}
}'Configured in store-layout.json:
| Zone | Camera | Notes |
|---|---|---|
| ENTRY | CAM_3 | Glass door threshold |
| SKINCARE | CAM_1 | DermDoc, Minimalist, Aqualogica, Pilgrim |
| HAIRCARE | CAM_1 | EB, TFS, GV, D&K |
| MAKEUP | CAM_2 | Maybelline, Faces, Lakme, Swiss+, Mars+NyBae |
| PREMIUM | CAM_2 | Alps, L'Oreal, Beauty |
| FRAGRANCE | CAM_1 | Specialty counter |
| FOH | CAM_1 | Front of House / Makeup Unit |
| BILLING | CAM_5 | Cash counter |
| Camera | Type |
|---|---|
| CAM_1, CAM_2 | Floor (zone dwell) |
| CAM_3 | Entry / exit |
| CAM_4 | Warehouse (excluded from customer metrics) |
| CAM_5 | Billing queue |
Copy environment template and adjust as needed:
cp .env.example .env| Variable | Default (example) | Notes |
|---|---|---|
DATABASE_URL |
sqlite:///./store_intelligence.db |
Compose uses sqlite:////app/db/store_intelligence.db in the API container |
APP_ENV |
development |
production in Docker Compose |
LOG_LEVEL |
INFO |
Structured request logging in app/main.py |
.env.example includes PostgreSQL variables for a future migration; the shipped Compose stack uses SQLite only.
Dashboard: API_URL (default http://localhost:8000; http://api:8000 inside Compose).
API only (from project root with venv):
python3 -m venv venv && source venv/bin/activate
pip install -r requirements-base.txt
export DATABASE_URL=sqlite:///./store_intelligence.db
uvicorn app.main:app --reload --port 8000Dashboard (separate terminal):
pip install -r requirements.dashboard.txt
API_URL=http://localhost:8000 streamlit run dashboard/app.pyFull pipeline requires requirements.txt (includes ML stack).
# API tests (default; ML tests ignored via pytest.ini)
pytest tests/ -v --tb=short| Test module | Coverage |
|---|---|
tests/test_pipeline.py |
Ingest idempotency, health, funnel, staff exclusion, re-entry |
tests/test_metrics.py |
Metrics, conversion, multi-store isolation |
tests/test_anomalies.py |
Anomaly types and severity |
ML/integration tests (test_yolo.py, test_tracker.py) are excluded from the default run.
| File | Contents |
|---|---|
requirements-base.txt |
FastAPI, SQLAlchemy, pytest, httpx |
requirements-ml.txt |
Ultralytics, PyTorch, OpenCV |
requirements.txt |
Base + ML (pipeline and API Docker image) |
requirements.dashboard.txt |
Streamlit, Pandas, Plotly |
| Issue | What to check |
|---|---|
Pipeline: No supported camera clips found |
Filenames must contain entry, zone, or billing; verify --dataset path |
Pipeline: ultralytics is not installed |
Run bash setup.sh and use the same Python as run.sh |
| Dashboard empty | Run pipeline or scripts/seed_demo_data.py; confirm API health |
/health shows STALE_FEED |
No events in the last 10 minutes — re-run ingest or pipeline |
| Ingest 409 | Expected for duplicate event_id (idempotent retry) |
Connection refused on /events/ingest |
API not running. pipeline/run.sh falls back to direct SQLite ingest (store_intelligence.db). For HTTP ingest, start the API first: docker compose up --build or uvicorn app.main:app --port 8000 |