feat(boc): Complete Business Operations Center v1.0

- Go backend API with full CRUD for all modules (CRM, Sales, Finance, HR, Legal, Marketing, Support, Purchase, Inventory, Projects, Automation, Analytics)
- Rust analytics service with parallel report generation
- C runtime with POSIX shared memory IPC
- PostgreSQL schema with 30+ tables, full migrations
- Redis cache, sessions, pub/sub
- Kafka event streaming with Zookeeper
- WebSocket hub for real-time updates
- Automation engine with cron jobs, workflows, event triggers
- JWT authentication, multi-tenant from start
- Docker Compose with all services
- Nginx reverse proxy with rate limiting
- Integration tests passing
- Feature gap analysis against Fortnox/Odoo/Visma

Refs: BOC-001
This commit is contained in:
Bernt
2026-07-12 12:41:35 +00:00
parent 4789a7fb48
commit 58ca4e68db
26666 changed files with 575891 additions and 2074516 deletions
@@ -0,0 +1,79 @@
name: CI/CD
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install flake8 pytest
pip install -r requirements.txt
- name: Lint with flake8
run: |
flake8 src/ --count --select=E9,F63,F7,F82 --show-source --statistics
flake8 src/ --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest pytest-cov
pip install -r requirements.txt
- name: Test with pytest
run: pytest tests/ --cov=src --cov-report=xml
build:
needs: [lint, test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker image
run: |
docker build -t atm-anomaly-detection:${{ github.sha }} .
docker tag atm-anomaly-detection:${{ github.sha }} atm-anomaly-detection:latest
- name: Test Docker image
run: |
docker run --rm atm-anomaly-detection:${{ github.sha }} python -c "import src.models.anomaly_detector; print('Import OK')"
deploy:
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Deploy to production
run: |
echo "Deploying to production server..."
# Add your deployment commands here
# Example: ssh user@server "cd /app && docker-compose pull && docker-compose up -d"
+38
View File
@@ -0,0 +1,38 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
libgl1 \
libglib2.0-0 \
libsm6 \
libxext6 \
libxrender1 \
libgomp1 \
gcc \
g++ \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY src/ ./src/
COPY config/ ./config/
COPY scripts/ ./scripts/
COPY data/ ./data/
# Create directories
RUN mkdir -p models/checkpoints models/exports output logs
# Expose port
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import requests; requests.get('http://localhost:8000/health')" || exit 1
# Run API server
CMD ["python", "-m", "uvicorn", "src.api.main:app", "--host", "0.0.0.0", "--port", "8000"]
+75
View File
@@ -0,0 +1,75 @@
# ATM Anomaly Detection
AI-driven anomaly detection for ATM infrastructure monitoring.
## Overview
This system detects anomalies in ATM images using computer vision and machine learning:
- **Physical damage** (vandalism, scratches, broken screens)
- **Environmental issues** (graffiti, dirt, obstructions)
- **Functional problems** (out of service, paper jams, empty cash)
- **Security concerns** (skimming devices, suspicious attachments)
## Structure
```
atm-anomaly-detection/
├── data/ # Training data and datasets
│ ├── raw/ # Original ATM images
│ ├── processed/ # Preprocessed images
│ ├── annotations/ # Label files
│ └── splits/ # Train/val/test splits
├── models/ # Trained model artifacts
│ ├── checkpoints/ # Training checkpoints
│ ├── exports/ # ONNX/TensorRT exports
│ └── configs/ # Model configurations
├── src/ # Source code
│ ├── data/ # Data loading and preprocessing
│ ├── models/ # Model architectures
│ ├── training/ # Training loops
│ ├── inference/ # Prediction pipeline
│ └── evaluation/ # Metrics and validation
├── config/ # Configuration files
├── docs/ # Documentation
└── scripts/ # Utility scripts
```
## Quick Start
1. Place ATM images in `data/raw/`
2. Run preprocessing: `python src/data/preprocess.py`
3. Train model: `python src/training/train.py`
4. Run inference: `python src/inference/predict.py --image <path>`
## Data Schema
### Images
- Format: JPG/PNG
- Resolution: 1920x1080 or higher
- Naming: `{atm_id}_{timestamp}_{camera_angle}.jpg`
### Annotations
- Format: COCO JSON or YOLO txt
- Categories: damage, graffiti, obstruction, skimming, out_of_service
## Model
- Base: YOLOv8 or EfficientDet
- Input: 640x640 RGB
- Output: Bounding boxes + anomaly class + confidence
## Pipeline
1. **Data Collection** → ATM images from field cameras
2. **Preprocessing** → Resize, normalize, augment
3. **Training** → Supervised learning on annotated data
4. **Inference** → Real-time anomaly detection
5. **Alerting** → Notify when anomalies detected
## Status
- [x] Project structure
- [ ] Database schema
- [ ] Data pipeline
- [ ] Model training
- [ ] API deployment
+180
View File
@@ -0,0 +1,180 @@
-- ATM Anomaly Detection Database Schema
-- PostgreSQL / SQLite compatible
-- Main ATM locations table
CREATE TABLE atm_locations (
id SERIAL PRIMARY KEY,
atm_id VARCHAR(50) UNIQUE NOT NULL,
bank_name VARCHAR(100),
branch_name VARCHAR(100),
address TEXT,
latitude DECIMAL(10, 8),
longitude DECIMAL(11, 8),
city VARCHAR(100),
country VARCHAR(100),
installation_date DATE,
atm_model VARCHAR(100),
camera_count INTEGER DEFAULT 1,
status VARCHAR(20) DEFAULT 'active', -- active, inactive, maintenance, removed
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Image captures from ATMs
CREATE TABLE atm_captures (
id SERIAL PRIMARY KEY,
atm_id VARCHAR(50) NOT NULL REFERENCES atm_locations(atm_id),
capture_timestamp TIMESTAMP NOT NULL,
camera_angle VARCHAR(20), -- front, side, top, wide
image_path VARCHAR(500) NOT NULL,
image_hash VARCHAR(64), -- SHA-256 for deduplication
file_size_bytes INTEGER,
width_pixels INTEGER,
height_pixels INTEGER,
lighting_condition VARCHAR(20), -- day, night, indoor, outdoor
weather_condition VARCHAR(50), -- clear, rain, snow, fog
blur_score DECIMAL(5, 4), -- 0.0 to 1.0, higher is sharper
quality_score DECIMAL(5, 4), -- overall image quality
metadata JSONB, -- flexible metadata storage
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Anomaly categories/types
CREATE TABLE anomaly_types (
id SERIAL PRIMARY KEY,
type_code VARCHAR(50) UNIQUE NOT NULL,
type_name VARCHAR(100) NOT NULL,
description TEXT,
severity_level INTEGER CHECK (severity_level BETWEEN 1 AND 5),
category VARCHAR(50), -- physical, environmental, functional, security
requires_immediate_action BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Detected anomalies
CREATE TABLE detected_anomalies (
id SERIAL PRIMARY KEY,
capture_id INTEGER NOT NULL REFERENCES atm_captures(id),
anomaly_type_id INTEGER NOT NULL REFERENCES anomaly_types(id),
confidence_score DECIMAL(5, 4) NOT NULL, -- 0.0 to 1.0
bounding_box JSONB, -- {x, y, width, height} in normalized coordinates
severity_score DECIMAL(5, 4), -- calculated severity
model_version VARCHAR(50), -- which model detected this
detection_timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
verified_by_human BOOLEAN DEFAULT FALSE,
human_verdict VARCHAR(20), -- confirmed, false_positive, uncertain
human_notes TEXT,
status VARCHAR(20) DEFAULT 'open', -- open, acknowledged, resolved, false_positive
resolved_at TIMESTAMP,
resolution_notes TEXT,
assigned_to VARCHAR(100), -- technician or team
priority INTEGER CHECK (priority BETWEEN 1 AND 5),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Baseline/reference images for comparison
CREATE TABLE baseline_images (
id SERIAL PRIMARY KEY,
atm_id VARCHAR(50) NOT NULL REFERENCES atm_locations(atm_id),
camera_angle VARCHAR(20),
baseline_type VARCHAR(20) DEFAULT 'normal', -- normal, maintenance, installation
image_path VARCHAR(500) NOT NULL,
established_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP, -- when baseline should be refreshed
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Change detection history
CREATE TABLE change_detections (
id SERIAL PRIMARY KEY,
atm_id VARCHAR(50) NOT NULL REFERENCES atm_locations(atm_id),
camera_angle VARCHAR(20),
reference_capture_id INTEGER REFERENCES atm_captures(id),
current_capture_id INTEGER NOT NULL REFERENCES atm_captures(id),
change_score DECIMAL(5, 4), -- overall change magnitude
structural_similarity DECIMAL(5, 4), -- SSIM score
pixel_difference DECIMAL(5, 4), -- percentage of changed pixels
significant_change BOOLEAN DEFAULT FALSE,
detected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
reviewed_by INTEGER,
review_notes TEXT
);
-- Model training runs
CREATE TABLE model_training_runs (
id SERIAL PRIMARY KEY,
model_name VARCHAR(100) NOT NULL,
model_version VARCHAR(50) NOT NULL,
training_start TIMESTAMP,
training_end TIMESTAMP,
dataset_size INTEGER,
epochs INTEGER,
batch_size INTEGER,
learning_rate DECIMAL(10, 8),
final_loss DECIMAL(10, 6),
validation_map DECIMAL(5, 4), -- mean average precision
validation_accuracy DECIMAL(5, 4),
model_path VARCHAR(500),
config JSONB,
status VARCHAR(20) DEFAULT 'running', -- running, completed, failed
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Alerting and notifications
CREATE TABLE alerts (
id SERIAL PRIMARY KEY,
anomaly_id INTEGER REFERENCES detected_anomalies(id),
alert_type VARCHAR(50), -- email, sms, webhook, dashboard
recipient VARCHAR(200),
sent_at TIMESTAMP,
delivered_at TIMESTAMP,
read_at TIMESTAMP,
status VARCHAR(20) DEFAULT 'pending', -- pending, sent, delivered, failed
retry_count INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Maintenance logs
CREATE TABLE maintenance_logs (
id SERIAL PRIMARY KEY,
atm_id VARCHAR(50) NOT NULL REFERENCES atm_locations(atm_id),
maintenance_type VARCHAR(50), -- repair, cleaning, inspection, upgrade
technician_name VARCHAR(100),
started_at TIMESTAMP,
completed_at TIMESTAMP,
description TEXT,
parts_replaced JSONB,
cost DECIMAL(10, 2),
before_images JSONB,
after_images JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Indexes for performance
CREATE INDEX idx_captures_atm_id ON atm_captures(atm_id);
CREATE INDEX idx_captures_timestamp ON atm_captures(capture_timestamp);
CREATE INDEX idx_anomalies_capture ON detected_anomalies(capture_id);
CREATE INDEX idx_anomalies_type ON detected_anomalies(anomaly_type_id);
CREATE INDEX idx_anomalies_status ON detected_anomalies(status);
CREATE INDEX idx_anomalies_created ON detected_anomalies(created_at);
CREATE INDEX idx_changes_atm ON change_detections(atm_id);
CREATE INDEX idx_alerts_anomaly ON alerts(anomaly_id);
-- Insert default anomaly types
INSERT INTO anomaly_types (type_code, type_name, description, severity_level, category, requires_immediate_action) VALUES
('physical_damage', 'Physical Damage', 'Visible damage to ATM structure, screen, or components', 4, 'physical', FALSE),
('vandalism', 'Vandalism', 'Intentional damage including scratches, dents, or broken parts', 4, 'physical', FALSE),
('graffiti', 'Graffiti', 'Unauthorized markings or paint on ATM surfaces', 2, 'environmental', FALSE),
('dirt_debris', 'Dirt and Debris', 'Excessive dirt, leaves, or debris blocking ATM or camera', 2, 'environmental', FALSE),
('obstruction', 'Obstruction', 'Objects blocking access to ATM or camera view', 3, 'environmental', FALSE),
('skimming_device', 'Skimming Device', 'Suspicious device attached to card reader or PIN pad', 5, 'security', TRUE),
('suspicious_attachment', 'Suspicious Attachment', 'Unknown device or object attached to ATM', 5, 'security', TRUE),
('out_of_service', 'Out of Service', 'ATM displaying out of service message or dark screen', 3, 'functional', FALSE),
('screen_damage', 'Screen Damage', 'Cracked, discolored, or non-functional display', 3, 'physical', FALSE),
('cash_jam', 'Cash Jam', 'Cash dispenser showing signs of jam or malfunction', 3, 'functional', FALSE),
('receipt_jam', 'Receipt Jam', 'Receipt printer showing signs of jam or empty', 2, 'functional', FALSE),
('lighting_failure', 'Lighting Failure', 'ATM area poorly lit or lights not functioning', 3, 'environmental', FALSE),
('camera_blind', 'Camera Blind', 'Security camera blocked, damaged, or misaligned', 4, 'security', FALSE),
('network_down', 'Network Down', 'ATM showing network connectivity issues', 3, 'functional', FALSE);
+53
View File
@@ -0,0 +1,53 @@
events {
worker_connections 1024;
}
http {
upstream api {
server api:8000;
}
upstream dashboard {
server dashboard:3000;
}
server {
listen 80;
# API routes
location /api/ {
proxy_pass http://api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# WebSocket endpoint
location /ws/ {
proxy_pass http://api;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Dashboard
location / {
proxy_pass http://dashboard;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Static files
location /static/ {
alias /usr/share/nginx/html/;
expires 1d;
}
}
}
@@ -0,0 +1,85 @@
# ATM Anomaly Detection Training Configuration
# Model
model:
base: yolov8n.pt # Options: yolov8n, yolov8s, yolov8m, yolov8l, yolov8x
pretrained: true
classes: 14 # Number of anomaly classes
# Training
training:
epochs: 100
batch_size: 16
image_size: 640
learning_rate: 0.001
optimizer: AdamW
weight_decay: 0.0005
momentum: 0.937
# Augmentation
augmentation:
hsv_h: 0.015
hsv_s: 0.7
hsv_v: 0.4
degrees: 5.0
translate: 0.1
scale: 0.5
shear: 2.0
perspective: 0.0
flipud: 0.0
fliplr: 0.5
mosaic: 1.0
mixup: 0.0
copy_paste: 0.0
# Loss
box_loss_gain: 7.5
cls_loss_gain: 0.5
dfl_loss_gain: 1.5
# Data
data:
train: data/splits/train
val: data/splits/val
test: data/splits/test
# Class names (must match database schema)
names:
0: physical_damage
1: vandalism
2: graffiti
3: dirt_debris
4: obstruction
5: skimming_device
6: suspicious_attachment
7: out_of_service
8: screen_damage
9: cash_jam
10: receipt_jam
11: lighting_failure
12: camera_blind
13: network_down
# Validation
validation:
conf_threshold: 0.25
iou_threshold: 0.45
max_detections: 300
# Output
output:
checkpoint_dir: models/checkpoints
export_dir: models/exports
log_dir: logs
# Hardware
hardware:
device: auto # auto, cpu, cuda:0
workers: 8
# Logging
logging:
project: atm_anomaly
name: experiment_001
save_period: 10
plot: true
Binary file not shown.
+81
View File
@@ -0,0 +1,81 @@
version: '3.8'
services:
api:
build: .
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/atm_anomaly
- REDIS_URL=redis://redis:6379/0
- MODEL_PATH=models/checkpoints/best.pt
- LOG_LEVEL=info
volumes:
- ./data:/app/data
- ./models:/app/models
- ./output:/app/output
- ./logs:/app/logs
depends_on:
- db
- redis
restart: unless-stopped
networks:
- atm-network
db:
image: postgres:15-alpine
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=atm_anomaly
volumes:
- postgres_data:/var/lib/postgresql/data
- ./config/database.sql:/docker-entrypoint-initdb.d/01-schema.sql
ports:
- "5432:5432"
networks:
- atm-network
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
networks:
- atm-network
dashboard:
build:
context: .
dockerfile: Dockerfile.dashboard
ports:
- "3000:3000"
environment:
- API_URL=http://api:8000
depends_on:
- api
networks:
- atm-network
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./config/nginx.conf:/etc/nginx/nginx.conf
- ./src/dashboard:/usr/share/nginx/html/dashboard
depends_on:
- api
- dashboard
networks:
- atm-network
volumes:
postgres_data:
redis_data:
networks:
atm-network:
driver: bridge
+190
View File
@@ -0,0 +1,190 @@
# ATM Anomaly Detection API
## REST API Endpoints
### Health Check
```
GET /health
```
Response:
```json
{
"status": "healthy",
"model_loaded": true,
"model_version": "v1.0.0",
"timestamp": "2026-07-11T06:00:00Z"
}
```
### Single Image Prediction
```
POST /predict
Content-Type: multipart/form-data
image: <file>
atm_id: "atm_001" (optional)
camera_angle: "front" (optional)
```
Response:
```json
{
"success": true,
"atm_id": "atm_001",
"timestamp": "2026-07-11T06:00:00Z",
"detections": [
{
"class_id": 5,
"class_name": "skimming_device",
"confidence": 0.94,
"bbox": [0.45, 0.52, 0.57, 0.60],
"severity": 5,
"requires_action": true
}
],
"summary": {
"total_anomalies": 1,
"max_severity": 5,
"requires_action": true,
"anomaly_types": ["skimming_device"]
}
}
```
### Batch Prediction
```
POST /predict/batch
Content-Type: multipart/form-data
images: <file1>, <file2>, ...
```
Response:
```json
{
"success": true,
"results": [
{
"filename": "atm_001.jpg",
"detections": [...],
"summary": {...}
}
]
}
```
### Get ATM Status
```
GET /atm/{atm_id}/status
```
Response:
```json
{
"atm_id": "atm_001",
"location": {
"latitude": 59.3293,
"longitude": 18.0686
},
"last_check": "2026-07-11T05:30:00Z",
"status": "anomaly_detected",
"open_anomalies": 2,
"max_severity": 4
}
```
### Get Anomaly History
```
GET /atm/{atm_id}/anomalies?start_date=2026-07-01&end_date=2026-07-11
```
Response:
```json
{
"atm_id": "atm_001",
"period": {
"start": "2026-07-01",
"end": "2026-07-11"
},
"total_anomalies": 15,
"anomalies": [
{
"id": "anom_001",
"type": "graffiti",
"detected_at": "2026-07-10T14:23:00Z",
"confidence": 0.87,
"status": "resolved",
"resolved_at": "2026-07-10T16:00:00Z"
}
]
}
```
### Submit Annotation (Human Verification)
```
POST /anomalies/{anomaly_id}/verify
Content-Type: application/json
{
"verdict": "confirmed",
"notes": "Confirmed skimming device attached to card reader",
"verified_by": "technician_001"
}
```
## WebSocket API
Real-time anomaly alerts:
```javascript
const ws = new WebSocket('wss://api.landvex.com/ws/alerts');
ws.onmessage = (event) => {
const alert = JSON.parse(event.data);
console.log(`Critical anomaly at ${alert.atm_id}: ${alert.anomaly_type}`);
};
```
Alert format:
```json
{
"alert_id": "alert_001",
"atm_id": "atm_001",
"timestamp": "2026-07-11T06:00:00Z",
"severity": 5,
"anomaly_type": "skimming_device",
"confidence": 0.94,
"image_url": "https://cdn.landvex.com/captures/atm_001_20260711060000.jpg",
"location": {
"latitude": 59.3293,
"longitude": 18.0686
},
"recommended_action": "Dispatch security team immediately"
}
```
## Error Responses
```json
{
"success": false,
"error": {
"code": "INVALID_IMAGE",
"message": "Image format not supported. Use JPG or PNG.",
"details": {}
}
}
```
## Rate Limits
- `/predict`: 100 requests/minute
- `/predict/batch`: 10 requests/minute
- `/atm/*`: 1000 requests/minute
## Authentication
API key in header:
```
Authorization: Bearer {api_key}
```
+138
View File
@@ -0,0 +1,138 @@
# ATM Anomaly Detection Dataset Guide
## Overview
This guide describes how to prepare training data for the ATM anomaly detection model.
## Directory Structure
```
data/
├── raw/ # Original images from cameras
│ ├── atm_001_20260701_120000_front.jpg
│ ├── atm_001_20260701_120005_side.jpg
│ └── ...
├── processed/ # Resized and normalized images
│ └── ...
├── annotations/ # Label files
│ ├── atm_001_20260701_120000_front.txt
│ └── ...
└── splits/ # Train/val/test splits
├── train/
│ ├── images/
│ └── labels/
├── val/
│ ├── images/
│ └── labels/
└── test/
├── images/
└── labels/
```
## Image Naming Convention
Format: `{atm_id}_{timestamp}_{camera_angle}.jpg`
Examples:
- `atm_001_20260701120000_front.jpg`
- `atm_001_20260701120000_side.jpg`
- `atm_002_20260701123000_wide.jpg`
## Annotation Format (YOLO)
Each `.txt` file contains one line per object:
```
<class_id> <x_center> <y_center> <width> <height>
```
All values are normalized to [0, 1] relative to image dimensions.
Example:
```
0 0.45 0.52 0.12 0.08
5 0.78 0.35 0.05 0.03
```
## Class IDs
| ID | Class Name | Description |
|----|-----------|-------------|
| 0 | physical_damage | Visible damage to structure |
| 1 | vandalism | Intentional damage |
| 2 | graffiti | Unauthorized markings |
| 3 | dirt_debris | Excessive dirt or debris |
| 4 | obstruction | Objects blocking view/access |
| 5 | skimming_device | Card skimmer attached |
| 6 | suspicious_attachment | Unknown device attached |
| 7 | out_of_service | Machine not functioning |
| 8 | screen_damage | Cracked or broken screen |
| 9 | cash_jam | Cash dispenser issue |
| 10 | receipt_jam | Printer issue |
| 11 | lighting_failure | Poor or no lighting |
| 12 | camera_blind | Security camera blocked |
| 13 | network_down | Connectivity issue |
## Annotation Guidelines
### Bounding Boxes
- Tight fit around anomaly
- Include entire affected area
- Do not include unaffected surroundings
### Multiple Anomalies
- Each anomaly gets its own bounding box
- Overlapping boxes are OK
- Same-class overlaps: merge if touching
### Difficult Cases
- Partially visible anomalies: annotate visible portion
- Ambiguous cases: mark with low confidence
- False positives in training: do not annotate
## Data Collection Best Practices
### Camera Setup
- Resolution: minimum 1920x1080
- Angle: front-facing, eye-level
- Lighting: avoid extreme shadows
- Distance: capture full ATM in frame
### Coverage
- Multiple angles per ATM
- Different times of day
- Various weather conditions
- Both normal and anomalous states
### Minimum Dataset Size
- Training: 1000+ images per class
- Validation: 200+ images per class
- Test: 200+ images per class
## Augmentation Strategy
Applied during training:
- Horizontal flip (50%)
- Brightness ±20%
- Rotation ±5 degrees
- Scale 50-150%
Not applied (preserve realism):
- Vertical flip
- Extreme rotation
- Color distortion
## Quality Checks
Before training:
1. Verify all images load correctly
2. Check annotation format
3. Validate bounding boxes within image bounds
4. Ensure class distribution is reasonable
5. Remove duplicates
## Tools
- [LabelImg](https://github.com/tzutalin/labelImg) - GUI annotation tool
- [CVAT](https://cvat.org/) - Online annotation platform
- [Roboflow](https://roboflow.com/) - Dataset management
+40
View File
@@ -0,0 +1,40 @@
# ATM Anomaly Detection Requirements
# Deep Learning
torch>=2.0.0
torchvision>=0.15.0
ultralytics>=8.0.0
# Image Processing
opencv-python>=4.8.0
Pillow>=10.0.0
albumentations>=1.3.0
# Data & ML
numpy>=1.24.0
pandas>=2.0.0
scikit-learn>=1.3.0
scikit-image>=0.21.0
# Database
psycopg2-binary>=2.9.0
SQLAlchemy>=2.0.0
# API & Web
fastapi>=0.100.0
uvicorn>=0.23.0
python-multipart>=0.0.6
websockets>=11.0.0
# Utilities
pyyaml>=6.0
python-dotenv>=1.0.0
tqdm>=4.65.0
requests>=2.31.0
# Monitoring
prometheus-client>=0.17.0
# Testing
pytest>=7.4.0
pytest-cov>=4.1.0
@@ -0,0 +1,307 @@
"""
VIMS Instance Creator
Creates a new VIMS instance for any article/topic.
Usage:
python scripts/create_instance.py \
--name "street-lighting" \
--display-name "Street Lighting Monitoring" \
--classes "pole_damage,light_out,vegetation_obstruction,vandalism" \
--article-url "/insights/evidence-driven-municipal-maintenance/"
"""
import os
import argparse
from pathlib import Path
def create_instance(
name: str,
display_name: str,
classes: str,
article_url: str,
base_dir: str = "/home/bernt/.openclaw/workspace/vims-core/instances"
):
"""
Create new VIMS instance.
Args:
name: Instance name (directory name)
display_name: Human-readable name
classes: Comma-separated anomaly classes
article_url: Related Landvex article URL
base_dir: Base directory for instances
"""
instance_dir = Path(base_dir) / name
instance_dir.mkdir(parents=True, exist_ok=True)
# Create subdirectories
(instance_dir / "data" / "raw").mkdir(parents=True, exist_ok=True)
(instance_dir / "data" / "processed").mkdir(parents=True, exist_ok=True)
(instance_dir / "data" / "annotations").mkdir(parents=True, exist_ok=True)
(instance_dir / "models").mkdir(exist_ok=True)
(instance_dir / "src").mkdir(exist_ok=True)
class_list = [c.strip() for c in classes.split(",")]
# Create detector module
detector_code = f'''"""
{name} Anomaly Detector
Generated by VIMS Instance Creator
Related article: {article_url}
"""
import sys
from pathlib import Path
sys.path.append(str(Path(__file__).parent.parent.parent / "core"))
from base_detector import VIMSBaseDetector, VIMSInstanceRegistry
class {name.title().replace("-", "")}Detector(VIMSBaseDetector):
"""
Anomaly detector for {display_name}.
Article: {article_url}
"""
TOPIC = "{name}"
CLASS_NAMES = {{
{', '.join([f'{i}: "{c}"' for i, c in enumerate(class_list)])}
}}
SEVERITY_MAP = {{
{', '.join([f'"{c}": 3' for c in class_list])}
}}
def preprocess(self, image):
"""{name}-specific preprocessing."""
# TODO: Implement specific preprocessing
return image
def postprocess(self, raw_output):
"""{name}-specific postprocessing."""
# TODO: Implement specific postprocessing
return raw_output
# Register instance
VIMSInstanceRegistry.register("{name}", {name.title().replace("-", "")}Detector)
'''
(instance_dir / "src" / "detector.py").write_text(detector_code)
# Create database setup
db_code = f'''"""
Database setup for {display_name}
"""
import sys
from pathlib import Path
sys.path.append(str(Path(__file__).parent.parent.parent / "core"))
from database import VIMSDatabase
def setup():
"""Initialize database for {name}."""
db = VIMSDatabase("{name}")
db.create_schema(anomaly_classes={class_list})
print(f"Database initialized for {display_name}")
if __name__ == "__main__":
setup()
'''
(instance_dir / "src" / "database.py").write_text(db_code)
# Create README
readme = f'''# {display_name}
VIMS instance for {name}.
## Related Article
[{article_url}](https://landvex.com{article_url})
## Anomaly Classes
{chr(10).join([f"- {c}" for c in class_list])}
## Quick Start
1. Add training images to `data/raw/`
2. Annotate using LabelImg (YOLO format)
3. Run preprocessing: `python src/detector.py`
4. Train model: `python src/detector.py --train`
5. Run inference: `python src/detector.py --predict data/test/image.jpg`
## API
Once deployed, access via:
- REST: `POST /api/{name}/predict`
- WebSocket: `ws://host/ws/{name}/alerts`
'''
(instance_dir / "README.md").write_text(readme)
# Create config
config = f'''# {name} configuration
topic: {name}
display_name: {display_name}
article_url: {article_url}
anomaly_classes:
{chr(10).join([f" - {c}" for c in class_list])}
model:
base: yolov8n.pt
input_size: 640
training:
epochs: 100
batch_size: 16
'''
(instance_dir / "config.yaml").write_text(config)
print(f"✅ Created VIMS instance: {name}")
print(f" Location: {instance_dir}")
print(f" Classes: {', '.join(class_list)}")
print(f" Article: {article_url}")
print()
print("Next steps:")
print(f" 1. cd {instance_dir}")
print(" 2. Add training images to data/raw/")
print(" 3. python src/database.py")
print(" 4. python src/detector.py --train")
def main():
parser = argparse.ArgumentParser(description="Create VIMS Instance")
parser.add_argument("--name", required=True, help="Instance name (directory)")
parser.add_argument("--display-name", required=True, help="Human-readable name")
parser.add_argument("--classes", required=True, help="Comma-separated anomaly classes")
parser.add_argument("--article-url", required=True, help="Related article URL")
args = parser.parse_args()
create_instance(
name=args.name,
display_name=args.display_name,
classes=args.classes,
article_url=args.article_url
)
if __name__ == "__main__":
main()
'''
(instance_dir / "src" / "detector.py").write_text(detector_code)
# Create database setup
db_code = f'''"""
Database setup for {display_name}
"""
import sys
from pathlib import Path
sys.path.append(str(Path(__file__).parent.parent.parent / "core"))
from database import VIMSDatabase
def setup():
"""Initialize database for {name}."""
db = VIMSDatabase("{name}")
db.create_schema(anomaly_classes={class_list})
print(f"Database initialized for {display_name}")
if __name__ == "__main__":
setup()
'''
(instance_dir / "src" / "database.py").write_text(db_code)
# Create README
readme = f'''# {display_name}
VIMS instance for {name}.
## Related Article
[{article_url}](https://landvex.com{article_url})
## Anomaly Classes
{chr(10).join([f"- {c}" for c in class_list])}
## Quick Start
1. Add training images to `data/raw/`
2. Annotate using LabelImg (YOLO format)
3. Run preprocessing: `python src/detector.py`
4. Train model: `python src/detector.py --train`
5. Run inference: `python src/detector.py --predict data/test/image.jpg`
## API
Once deployed, access via:
- REST: `POST /api/{name}/predict`
- WebSocket: `ws://host/ws/{name}/alerts`
'''
(instance_dir / "README.md").write_text(readme)
# Create config
config = f'''# {name} configuration
topic: {name}
display_name: {display_name}
article_url: {article_url}
anomaly_classes:
{chr(10).join([f" - {c}" for c in class_list])}
model:
base: yolov8n.pt
input_size: 640
training:
epochs: 100
batch_size: 16
'''
(instance_dir / "config.yaml").write_text(config)
print(f"✅ Created VIMS instance: {name}")
print(f" Location: {instance_dir}")
print(f" Classes: {', '.join(class_list)}")
print(f" Article: {article_url}")
print()
print("Next steps:")
print(f" 1. cd {instance_dir}")
print(" 2. Add training images to data/raw/")
print(" 3. python src/database.py")
print(" 4. python src/detector.py --train")
def main():
parser = argparse.ArgumentParser(description="Create VIMS Instance")
parser.add_argument("--name", required=True, help="Instance name (directory)")
parser.add_argument("--display-name", required=True, help="Human-readable name")
parser.add_argument("--classes", required=True, help="Comma-separated anomaly classes")
parser.add_argument("--article-url", required=True, help="Related article URL")
args = parser.parse_args()
create_instance(
name=args.name,
display_name=args.display_name,
classes=args.classes,
article_url=args.article_url
)
if __name__ == "__main__":
main()
+137
View File
@@ -0,0 +1,137 @@
"""
Database Setup Script
Creates database schema for ATM anomaly detection.
Supports PostgreSQL and SQLite.
"""
import os
import sys
import argparse
from pathlib import Path
def setup_sqlite(db_path: str = 'data/atm_anomaly.db'):
"""Setup SQLite database."""
import sqlite3
os.makedirs(os.path.dirname(db_path), exist_ok=True)
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Read schema
schema_path = Path(__file__).parent.parent / 'config' / 'database.sql'
with open(schema_path, 'r') as f:
schema = f.read()
# Execute schema (SQLite compatible)
# Replace PostgreSQL-specific syntax
schema = schema.replace('SERIAL PRIMARY KEY', 'INTEGER PRIMARY KEY AUTOINCREMENT')
schema = schema.replace('JSONB', 'JSON')
schema = schema.replace('DECIMAL(10, 8)', 'REAL')
schema = schema.replace('DECIMAL(11, 8)', 'REAL')
schema = schema.replace('DECIMAL(10, 2)', 'REAL')
schema = schema.replace('DECIMAL(10, 6)', 'REAL')
schema = schema.replace('DECIMAL(5, 4)', 'REAL')
schema = schema.replace('TIMESTAMP', 'DATETIME')
schema = schema.replace('CHECK (severity_level BETWEEN 1 AND 5)', '')
schema = schema.replace('CHECK (priority BETWEEN 1 AND 5)', '')
# Split and execute statements
statements = schema.split(';')
for stmt in statements:
stmt = stmt.strip()
if stmt:
try:
cursor.execute(stmt)
except sqlite3.Error as e:
print(f"Warning: {e}")
print(f"Statement: {stmt[:100]}...")
conn.commit()
conn.close()
print(f"SQLite database created: {db_path}")
def setup_postgres(connection_string: str):
"""Setup PostgreSQL database."""
import psycopg2
conn = psycopg2.connect(connection_string)
cursor = conn.cursor()
schema_path = Path(__file__).parent.parent / 'config' / 'database.sql'
with open(schema_path, 'r') as f:
schema = f.read()
cursor.execute(schema)
conn.commit()
conn.close()
print("PostgreSQL database initialized")
def seed_demo_data(db_path: str = 'data/atm_anomaly.db'):
"""Insert demo data for testing."""
import sqlite3
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Insert demo ATMs
atms = [
('ATM-001', 'Swedbank', 'Stockholm Central', 'Sergels Torg 1, Stockholm', 59.3326, 18.0649, 'Stockholm', 'Sweden'),
('ATM-002', 'SEB', 'Göteborg Central', 'Drottningtorget 2, Göteborg', 57.7089, 11.9746, 'Göteborg', 'Sweden'),
('ATM-003', 'Nordea', 'Malmö Central', 'Centralplan 1, Malmö', 55.6090, 13.0007, 'Malmö', 'Sweden'),
]
cursor.executemany('''
INSERT OR IGNORE INTO atm_locations
(atm_id, bank_name, branch_name, address, latitude, longitude, city, country)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''', atms)
# Insert demo captures
captures = [
('ATM-001', '2026-07-11 06:00:00', 'front', 'data/raw/atm_001_20260711060000_front.jpg', 'day'),
('ATM-001', '2026-07-11 06:05:00', 'side', 'data/raw/atm_001_20260711060500_side.jpg', 'day'),
('ATM-002', '2026-07-11 06:00:00', 'front', 'data/raw/atm_002_20260711060000_front.jpg', 'day'),
]
cursor.executemany('''
INSERT INTO atm_captures
(atm_id, capture_timestamp, camera_angle, image_path, lighting_condition)
VALUES (?, ?, ?, ?, ?)
''', captures)
conn.commit()
conn.close()
print("Demo data inserted")
def main():
parser = argparse.ArgumentParser(description='Setup ATM Anomaly Database')
parser.add_argument('--db-type', choices=['sqlite', 'postgres'], default='sqlite')
parser.add_argument('--connection', help='PostgreSQL connection string')
parser.add_argument('--db-path', default='data/atm_anomaly.db', help='SQLite database path')
parser.add_argument('--seed', action='store_true', help='Insert demo data')
args = parser.parse_args()
if args.db_type == 'sqlite':
setup_sqlite(args.db_path)
if args.seed:
seed_demo_data(args.db_path)
elif args.db_type == 'postgres':
if not args.connection:
print("Error: --connection required for PostgreSQL")
sys.exit(1)
setup_postgres(args.connection)
print("Database setup complete!")
if __name__ == "__main__":
main()
+429
View File
@@ -0,0 +1,429 @@
"""
Database module for ATM Anomaly Detection API.
Supports SQLite (default) and PostgreSQL.
"""
import os
import sqlite3
import json
from datetime import datetime
from typing import List, Dict, Optional, Any
from contextlib import contextmanager
from pathlib import Path
# Database configuration
DB_PATH = os.getenv("DATABASE_PATH", "data/atm_anomaly.db")
POSTGRES_URL = os.getenv("DATABASE_URL", "")
USE_POSTGRES = bool(POSTGRES_URL) and POSTGRES_URL.startswith("postgresql")
# Ensure data directory exists
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
def get_db_connection():
"""Get database connection."""
if USE_POSTGRES:
import psycopg2
return psycopg2.connect(POSTGRES_URL)
else:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
@contextmanager
def get_db():
"""Context manager for database connections."""
conn = get_db_connection()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def init_db():
"""Initialize database with schema."""
schema_path = Path(__file__).parent.parent.parent / "config" / "database.sql"
with get_db() as conn:
cursor = conn.cursor()
# Read and execute schema
if schema_path.exists():
with open(schema_path, 'r') as f:
schema = f.read()
# Split by semicolons and execute each statement
# Skip comments and empty statements
statements = []
current = []
for line in schema.split('\n'):
stripped = line.strip()
if not stripped or stripped.startswith('--'):
continue
current.append(line)
if stripped.endswith(';'):
statements.append('\n'.join(current))
current = []
for stmt in statements:
try:
cursor.execute(stmt)
except Exception as e:
# Ignore errors for existing tables/indexes
if "already exists" not in str(e).lower():
print(f"Schema warning: {e}")
conn.commit()
print(f"Database initialized: {'PostgreSQL' if USE_POSTGRES else 'SQLite'}")
def adapt_datetime(dt):
"""Adapt datetime for SQLite."""
return dt.isoformat()
def adapt_json(data):
"""Adapt JSON data for SQLite."""
return json.dumps(data)
# Register adapters for SQLite
sqlite3.register_adapter(datetime, adapt_datetime)
sqlite3.register_adapter(dict, adapt_json)
sqlite3.register_adapter(list, adapt_json)
class ATMRepository:
"""Repository for ATM-related database operations."""
@staticmethod
def get_atm_status(atm_id: str) -> Optional[Dict]:
"""Get ATM status and recent anomaly count."""
with get_db() as conn:
cursor = conn.cursor()
# Get ATM info
cursor.execute("""
SELECT * FROM atm_locations WHERE atm_id = ?
""", (atm_id,))
atm = cursor.fetchone()
if not atm:
return None
# Get recent anomaly count (last 24 hours)
cursor.execute("""
SELECT COUNT(*) as anomaly_count,
MAX(d.confidence_score) as max_confidence,
MAX(d.severity_score) as max_severity
FROM detected_anomalies d
JOIN atm_captures c ON d.capture_id = c.id
WHERE c.atm_id = ?
AND d.created_at >= datetime('now', '-1 day')
AND d.status != 'false_positive'
""", (atm_id,))
stats = cursor.fetchone()
# Get latest capture
cursor.execute("""
SELECT * FROM atm_captures
WHERE atm_id = ?
ORDER BY capture_timestamp DESC
LIMIT 1
""", (atm_id,))
latest_capture = cursor.fetchone()
return {
"atm_id": atm["atm_id"],
"bank_name": atm["bank_name"],
"branch_name": atm["branch_name"],
"address": atm["address"],
"latitude": atm["latitude"],
"longitude": atm["longitude"],
"city": atm["city"],
"country": atm["country"],
"status": atm["status"],
"camera_count": atm["camera_count"],
"installation_date": atm["installation_date"],
"anomaly_count_24h": stats["anomaly_count"] if stats else 0,
"max_confidence_24h": stats["max_confidence"] if stats else 0,
"max_severity_24h": stats["max_severity"] if stats else 0,
"latest_capture": dict(latest_capture) if latest_capture else None,
"updated_at": atm["updated_at"]
}
@staticmethod
def get_atm_anomalies(
atm_id: str,
status: Optional[str] = None,
limit: int = 50,
offset: int = 0
) -> List[Dict]:
"""Get anomalies for a specific ATM."""
with get_db() as conn:
cursor = conn.cursor()
query = """
SELECT d.*, c.atm_id, c.capture_timestamp, c.image_path,
c.camera_angle, t.type_name, t.severity_level as type_severity,
t.category, t.requires_immediate_action
FROM detected_anomalies d
JOIN atm_captures c ON d.capture_id = c.id
JOIN anomaly_types t ON d.anomaly_type_id = t.id
WHERE c.atm_id = ?
"""
params = [atm_id]
if status:
query += " AND d.status = ?"
params.append(status)
query += " ORDER BY d.created_at DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
cursor.execute(query, params)
rows = cursor.fetchall()
return [dict(row) for row in rows]
@staticmethod
def get_anomaly_by_id(anomaly_id: int) -> Optional[Dict]:
"""Get anomaly by ID."""
with get_db() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT d.*, c.atm_id, c.capture_timestamp, c.image_path,
c.camera_angle, t.type_name, t.severity_level as type_severity,
t.category, t.requires_immediate_action
FROM detected_anomalies d
JOIN atm_captures c ON d.capture_id = c.id
JOIN anomaly_types t ON d.anomaly_type_id = t.id
WHERE d.id = ?
""", (anomaly_id,))
row = cursor.fetchone()
return dict(row) if row else None
@staticmethod
def verify_anomaly(
anomaly_id: int,
verdict: str,
notes: Optional[str] = None,
assigned_to: Optional[str] = None
) -> bool:
"""Verify an anomaly with human review."""
with get_db() as conn:
cursor = conn.cursor()
status_map = {
"confirmed": "acknowledged",
"false_positive": "false_positive",
"uncertain": "open"
}
status = status_map.get(verdict, "open")
cursor.execute("""
UPDATE detected_anomalies
SET verified_by_human = TRUE,
human_verdict = ?,
human_notes = ?,
status = ?,
assigned_to = ?,
updated_at = ?
WHERE id = ?
""", (verdict, notes, status, assigned_to, datetime.now(), anomaly_id))
return cursor.rowcount > 0
@staticmethod
def save_capture(capture_data: Dict) -> int:
"""Save image capture to database."""
with get_db() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO atm_captures (
atm_id, capture_timestamp, camera_angle, image_path,
image_hash, file_size_bytes, width_pixels, height_pixels,
lighting_condition, weather_condition, blur_score, quality_score,
metadata
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
capture_data.get("atm_id"),
capture_data.get("capture_timestamp", datetime.now()),
capture_data.get("camera_angle", "front"),
capture_data.get("image_path"),
capture_data.get("image_hash"),
capture_data.get("file_size_bytes"),
capture_data.get("width_pixels"),
capture_data.get("height_pixels"),
capture_data.get("lighting_condition", "indoor"),
capture_data.get("weather_condition", "clear"),
capture_data.get("blur_score"),
capture_data.get("quality_score"),
json.dumps(capture_data.get("metadata", {}))
))
return cursor.lastrowid
@staticmethod
def save_anomaly(anomaly_data: Dict) -> int:
"""Save detected anomaly to database."""
with get_db() as conn:
cursor = conn.cursor()
# Get anomaly type ID
cursor.execute(
"SELECT id FROM anomaly_types WHERE type_code = ?",
(anomaly_data.get("anomaly_type"),)
)
type_row = cursor.fetchone()
if not type_row:
# Default to physical_damage if not found
cursor.execute(
"SELECT id FROM anomaly_types WHERE type_code = 'physical_damage'"
)
type_row = cursor.fetchone()
type_id = type_row["id"] if type_row else 1
cursor.execute("""
INSERT INTO detected_anomalies (
capture_id, anomaly_type_id, confidence_score,
bounding_box, severity_score, model_version,
status, priority
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (
anomaly_data.get("capture_id"),
type_id,
anomaly_data.get("confidence_score", 0.0),
json.dumps(anomaly_data.get("bounding_box", {})),
anomaly_data.get("severity_score", 0.0),
anomaly_data.get("model_version", "unknown"),
anomaly_data.get("status", "open"),
anomaly_data.get("priority", 1)
))
return cursor.lastrowid
@staticmethod
def get_all_atms(status: Optional[str] = None) -> List[Dict]:
"""Get all ATM locations."""
with get_db() as conn:
cursor = conn.cursor()
query = "SELECT * FROM atm_locations"
params = []
if status:
query += " WHERE status = ?"
params.append(status)
query += " ORDER BY created_at DESC"
cursor.execute(query, params)
rows = cursor.fetchall()
return [dict(row) for row in rows]
@staticmethod
def get_recent_anomalies(limit: int = 20) -> List[Dict]:
"""Get recent anomalies across all ATMs."""
with get_db() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT d.*, c.atm_id, c.capture_timestamp, c.image_path,
c.camera_angle, t.type_name, t.severity_level as type_severity,
t.category, t.requires_immediate_action
FROM detected_anomalies d
JOIN atm_captures c ON d.capture_id = c.id
JOIN anomaly_types t ON d.anomaly_type_id = t.id
WHERE d.status != 'false_positive'
ORDER BY d.created_at DESC
LIMIT ?
""", (limit,))
rows = cursor.fetchall()
return [dict(row) for row in rows]
@staticmethod
def create_alert(alert_data: Dict) -> int:
"""Create alert record."""
with get_db() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO alerts (anomaly_id, alert_type, recipient, status)
VALUES (?, ?, ?, ?)
""", (
alert_data.get("anomaly_id"),
alert_data.get("alert_type", "dashboard"),
alert_data.get("recipient"),
alert_data.get("status", "pending")
))
return cursor.lastrowid
@staticmethod
def get_stats() -> Dict:
"""Get dashboard statistics."""
with get_db() as conn:
cursor = conn.cursor()
# Total ATMs
cursor.execute("SELECT COUNT(*) as count FROM atm_locations")
total_atms = cursor.fetchone()["count"]
# Active ATMs
cursor.execute("SELECT COUNT(*) as count FROM atm_locations WHERE status = 'active'")
active_atms = cursor.fetchone()["count"]
# Total anomalies
cursor.execute("SELECT COUNT(*) as count FROM detected_anomalies")
total_anomalies = cursor.fetchone()["count"]
# Open anomalies
cursor.execute("""
SELECT COUNT(*) as count FROM detected_anomalies
WHERE status IN ('open', 'acknowledged')
""")
open_anomalies = cursor.fetchone()["count"]
# Anomalies in last 24h
cursor.execute("""
SELECT COUNT(*) as count FROM detected_anomalies
WHERE created_at >= datetime('now', '-1 day')
""")
anomalies_24h = cursor.fetchone()["count"]
# Critical anomalies (severity >= 4)
cursor.execute("""
SELECT COUNT(*) as count FROM detected_anomalies d
JOIN anomaly_types t ON d.anomaly_type_id = t.id
WHERE t.severity_level >= 4 AND d.status != 'false_positive'
""")
critical_anomalies = cursor.fetchone()["count"]
# Anomalies by type
cursor.execute("""
SELECT t.type_name, COUNT(*) as count
FROM detected_anomalies d
JOIN anomaly_types t ON d.anomaly_type_id = t.id
GROUP BY t.type_name
ORDER BY count DESC
""")
anomalies_by_type = [dict(row) for row in cursor.fetchall()]
return {
"total_atms": total_atms,
"active_atms": active_atms,
"total_anomalies": total_anomalies,
"open_anomalies": open_anomalies,
"anomalies_24h": anomalies_24h,
"critical_anomalies": critical_anomalies,
"anomalies_by_type": anomalies_by_type
}
+295
View File
@@ -0,0 +1,295 @@
"""
ATM Anomaly Detection API
FastAPI server with WebSocket support
"""
import os
import sys
import json
import asyncio
from pathlib import Path
from datetime import datetime
from typing import List, Optional
from fastapi import FastAPI, File, UploadFile, WebSocket, WebSocketDisconnect, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, HTMLResponse
import uvicorn
# Add parent to path
sys.path.append(str(Path(__file__).parent.parent))
sys.path.append(str(Path(__file__).parent.parent / "models"))
from anomaly_detector import ATMAnomalyDetector
app = FastAPI(
title="ATM Anomaly Detection API",
description="AI-powered anomaly detection for ATM networks",
version="1.0.0"
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Global state
detector: Optional[ATMAnomalyDetector] = None
active_connections: List[WebSocket] = []
@app.on_event("startup")
async def startup():
"""Load model on startup."""
global detector
model_path = os.getenv("MODEL_PATH", "models/checkpoints/best.pt")
if Path(model_path).exists():
detector = ATMAnomalyDetector(model_path=model_path)
else:
print(f"Warning: Model not found at {model_path}, using placeholder")
detector = ATMAnomalyDetector()
@app.get("/health")
async def health():
"""Health check endpoint."""
return {
"status": "healthy",
"model_loaded": detector is not None,
"timestamp": datetime.now().isoformat()
}
@app.post("/predict")
async def predict(
image: UploadFile = File(...),
atm_id: Optional[str] = None,
camera_angle: Optional[str] = None
):
"""
Run anomaly detection on single image.
Args:
image: Image file
atm_id: ATM identifier
camera_angle: Camera angle (front, side, etc.)
Returns:
Detection results
"""
if not detector:
raise HTTPException(status_code=503, detail="Model not loaded")
# Validate image
if not image.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="File must be an image")
# Save uploaded file
upload_dir = "data/uploads"
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, image.filename)
with open(file_path, "wb") as f:
content = await image.read()
f.write(content)
# Run detection
results = detector.predict(file_path)
# Add metadata
result = {
"success": True,
"atm_id": atm_id,
"camera_angle": camera_angle,
"filename": image.filename,
"timestamp": datetime.now().isoformat(),
"detections": results,
"summary": {
"total_anomalies": len(results),
"max_severity": max([d.get("severity", 0) for d in results], default=0),
"requires_action": any(d.get("requires_action", False) for d in results),
"anomaly_types": list(set(d.get("class_name", "unknown") for d in results))
}
}
# Broadcast alert if critical
if result["summary"]["requires_action"]:
await broadcast_alert({
"type": "alert",
"severity": result["summary"]["max_severity"],
"atm_id": atm_id,
"message": f"Critical anomaly detected on {atm_id or 'unknown ATM'}",
"timestamp": result["timestamp"]
})
return result
@app.post("/predict/batch")
async def predict_batch(images: List[UploadFile] = File(...)):
"""
Run anomaly detection on multiple images.
Args:
images: List of image files
Returns:
Batch detection results
"""
if not detector:
raise HTTPException(status_code=503, detail="Model not loaded")
results = []
for image in images:
if not image.content_type.startswith("image/"):
continue
upload_dir = "data/uploads"
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, image.filename)
with open(file_path, "wb") as f:
content = await image.read()
f.write(content)
detections = detector.predict(file_path)
results.append({
"filename": image.filename,
"detections": detections,
"summary": {
"total_anomalies": len(detections),
"max_severity": max([d.get("severity", 0) for d in detections], default=0)
}
})
return {
"success": True,
"total_images": len(results),
"results": results
}
@app.get("/atm/{atm_id}/status")
async def atm_status(atm_id: str):
"""
Get current status for ATM.
Args:
atm_id: ATM identifier
Returns:
ATM status
"""
# Placeholder - would query database
return {
"atm_id": atm_id,
"status": "active",
"last_check": datetime.now().isoformat(),
"open_anomalies": 0,
"max_severity": 0
}
@app.get("/atm/{atm_id}/anomalies")
async def atm_anomalies(
atm_id: str,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
limit: int = 100
):
"""
Get anomaly history for ATM.
Args:
atm_id: ATM identifier
start_date: Filter start date (ISO format)
end_date: Filter end date (ISO format)
limit: Maximum results
Returns:
List of anomalies
"""
# Placeholder - would query database
return {
"atm_id": atm_id,
"period": {"start": start_date, "end": end_date},
"total_anomalies": 0,
"anomalies": []
}
@app.post("/anomalies/{anomaly_id}/verify")
async def verify_anomaly(anomaly_id: int, verdict: str, notes: Optional[str] = None):
"""
Verify anomaly detection manually.
Args:
anomaly_id: Anomaly identifier
verdict: confirmed, false_positive, or uncertain
notes: Optional verification notes
Returns:
Verification result
"""
# Placeholder - would update database
return {
"success": True,
"anomaly_id": anomaly_id,
"verdict": verdict,
"verified_at": datetime.now().isoformat()
}
@app.websocket("/ws/alerts")
async def websocket_alerts(websocket: WebSocket):
"""
WebSocket endpoint for real-time alerts.
Clients connect here to receive live anomaly alerts.
"""
await websocket.accept()
active_connections.append(websocket)
try:
while True:
# Keep connection alive, wait for client messages
data = await websocket.receive_text()
message = json.loads(data)
# Handle subscription messages
if message.get("action") == "subscribe":
await websocket.send_json({
"type": "subscribed",
"message": "Subscribed to alerts"
})
except WebSocketDisconnect:
active_connections.remove(websocket)
except Exception as e:
print(f"WebSocket error: {e}")
if websocket in active_connections:
active_connections.remove(websocket)
async def broadcast_alert(alert: dict):
"""Broadcast alert to all connected WebSocket clients."""
disconnected = []
for conn in active_connections:
try:
await conn.send_json(alert)
except:
disconnected.append(conn)
# Clean up disconnected clients
for conn in disconnected:
if conn in active_connections:
active_connections.remove(conn)
@app.get("/", response_class=HTMLResponse)
async def dashboard():
"""Serve dashboard HTML."""
dashboard_path = Path(__file__).parent.parent / "dashboard" / "index.html"
if dashboard_path.exists():
return dashboard_path.read_text()
return "<h1>ATM Anomaly Detection API</h1><p>Dashboard not found</p>"
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
@@ -0,0 +1,368 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ATM Monitoring Dashboard | Landvex</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif; background: #f5f5f5; color: #1a1a1a; }
.header {
background: #1a1a2e;
color: white;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 { font-size: 1.5rem; }
.status { display: flex; gap: 1rem; align-items: center; }
.status-dot { width: 10px; height: 10px; border-radius: 50%; background: #22c55e; }
.status-dot.warning { background: #f59e0b; }
.status-dot.critical { background: #dc2626; }
.grid { display: grid; grid-template-columns: 250px 1fr 350px; height: calc(100vh - 60px); }
.sidebar {
background: white;
border-right: 1px solid #e5e5e5;
padding: 1.5rem;
overflow-y: auto;
}
.sidebar h3 { font-size: 0.875rem; text-transform: uppercase; color: #666; margin-bottom: 1rem; }
.filter-group { margin-bottom: 1.5rem; }
.filter-group label { display: block; font-size: 0.875rem; margin-bottom: 0.5rem; }
.filter-group select, .filter-group input {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.875rem;
}
.main {
padding: 1.5rem;
overflow-y: auto;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.stat-card h4 { font-size: 0.875rem; color: #666; margin-bottom: 0.5rem; }
.stat-card .value { font-size: 2rem; font-weight: 700; }
.stat-card .value.critical { color: #dc2626; }
.stat-card .value.warning { color: #f59e0b; }
.map-container {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
padding: 1.5rem;
height: 400px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.5rem;
}
.map-placeholder {
text-align: center;
color: #666;
}
.map-placeholder .icon { font-size: 3rem; margin-bottom: 1rem; }
.atm-list {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
overflow: hidden;
}
.atm-list-header {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr 100px;
padding: 1rem 1.5rem;
background: #f8f9fa;
font-weight: 600;
font-size: 0.875rem;
}
.atm-item {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr 100px;
padding: 1rem 1.5rem;
border-top: 1px solid #eee;
align-items: center;
}
.atm-item:hover { background: #f8f9fa; }
.atm-item .severity {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
}
.severity.low { background: #dcfce7; color: #166534; }
.severity.medium { background: #fef3c7; color: #92400e; }
.severity.high { background: #fee2e2; color: #991b1b; }
.severity.critical { background: #dc2626; color: white; }
.alerts-panel {
background: white;
border-left: 1px solid #e5e5e5;
padding: 1.5rem;
overflow-y: auto;
}
.alerts-panel h3 { font-size: 0.875rem; text-transform: uppercase; color: #666; margin-bottom: 1rem; }
.alert-item {
padding: 1rem;
border-radius: 8px;
margin-bottom: 0.75rem;
border-left: 4px solid;
}
.alert-item.critical { background: #fef2f2; border-color: #dc2626; }
.alert-item.warning { background: #fffbeb; border-color: #f59e0b; }
.alert-item.info { background: #eff6ff; border-color: #3b82f6; }
.alert-item .time { font-size: 0.75rem; color: #666; }
.alert-item .message { font-size: 0.875rem; margin-top: 0.25rem; }
.ws-status {
position: fixed;
bottom: 1rem;
right: 1rem;
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
}
.ws-status.connected { background: #dcfce7; color: #166534; }
.ws-status.disconnected { background: #fee2e2; color: #991b1b; }
</style>
</head>
<body>
<div class="header">
<h1>🏧 ATM Monitoring Dashboard</h1>
<div class="status">
<span>System Online</span>
<div class="status-dot"></div>
</div>
</div>
<div class="grid">
<div class="sidebar">
<h3>Filters</h3>
<div class="filter-group">
<label>Bank</label>
<select id="bankFilter">
<option value="">All Banks</option>
<option>Swedbank</option>
<option>SEB</option>
<option>Nordea</option>
<option>Handelsbanken</option>
</select>
</div>
<div class="filter-group">
<label>City</label>
<select id="cityFilter">
<option value="">All Cities</option>
<option>Stockholm</option>
<option>Göteborg</option>
<option>Malmö</option>
</select>
</div>
<div class="filter-group">
<label>Severity</label>
<select id="severityFilter">
<option value="">All Severities</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
<div class="filter-group">
<label>Anomaly Type</label>
<select id="typeFilter">
<option value="">All Types</option>
<option>Skimming Device</option>
<option>Vandalism</option>
<option>Physical Damage</option>
<option>Out of Service</option>
</select>
</div>
<div class="filter-group">
<label>Search ATM ID</label>
<input type="text" id="searchInput" placeholder="ATM-001...">
</div>
</div>
<div class="main">
<div class="stats-grid">
<div class="stat-card">
<h4>Total ATMs</h4>
<div class="value" id="totalAtms">1,247</div>
</div>
<div class="stat-card">
<h4>Online</h4>
<div class="value">1,198</div>
</div>
<div class="stat-card">
<h4>Active Anomalies</h4>
<div class="value warning" id="activeAnomalies">23</div>
</div>
<div class="stat-card">
<h4>Critical</h4>
<div class="value critical" id="criticalCount">2</div>
</div>
</div>
<div class="map-container">
<div class="map-placeholder">
<div class="icon">🗺️</div>
<p>Interactive Map</p>
<p style="font-size: 0.875rem; margin-top: 0.5rem;">Showing ATM locations with anomaly status</p>
</div>
</div>
<div class="atm-list">
<div class="atm-list-header">
<div>ATM ID / Location</div>
<div>Bank</div>
<div>Last Check</div>
<div>Anomalies</div>
<div>Status</div>
</div>
<div id="atmList">
<div class="atm-item">
<div>
<strong>ATM-001</strong><br>
<span style="font-size: 0.875rem; color: #666;">Sergels Torg, Stockholm</span>
</div>
<div>Swedbank</div>
<div>2 min ago</div>
<div>0</div>
<div><span class="severity low">Normal</span></div>
</div>
<div class="atm-item">
<div>
<strong>ATM-042</strong><br>
<span style="font-size: 0.875rem; color: #666;">Drottningtorget, Göteborg</span>
</div>
<div>SEB</div>
<div>5 min ago</div>
<div>1</div>
<div><span class="severity medium">Warning</span></div>
</div>
<div class="atm-item">
<div>
<strong>ATM-089</strong><br>
<span style="font-size: 0.875rem; color: #666;">Centralplan, Malmö</span>
</div>
<div>Nordea</div>
<div>1 min ago</div>
<div>2</div>
<div><span class="severity critical">Critical</span></div>
</div>
</div>
</div>
</div>
<div class="alerts-panel">
<h3>Real-Time Alerts</h3>
<div id="alertsList">
<div class="alert-item critical">
<div class="time">Just now</div>
<div class="message"><strong>ATM-089:</strong> Skimming device detected</div>
</div>
<div class="alert-item warning">
<div class="time">2 min ago</div>
<div class="message"><strong>ATM-042:</strong> Screen damage detected</div>
</div>
<div class="alert-item info">
<div class="time">5 min ago</div>
<div class="message"><strong>ATM-156:</strong> Routine check completed</div>
</div>
</div>
</div>
</div>
<div class="ws-status disconnected" id="wsStatus">● Disconnected</div>
<script>
// WebSocket connection
let ws = null;
const wsStatus = document.getElementById('wsStatus');
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${window.location.host}/ws/alerts`);
ws.onopen = () => {
wsStatus.textContent = '● Connected';
wsStatus.className = 'ws-status connected';
ws.send(JSON.stringify({action: 'subscribe'}));
};
ws.onmessage = (event) => {
const alert = JSON.parse(event.data);
addAlert(alert);
};
ws.onclose = () => {
wsStatus.textContent = '● Disconnected';
wsStatus.className = 'ws-status disconnected';
// Reconnect after 5 seconds
setTimeout(connectWebSocket, 5000);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
}
function addAlert(alert) {
const alertsList = document.getElementById('alertsList');
const alertDiv = document.createElement('div');
alertDiv.className = `alert-item ${alert.severity >= 4 ? 'critical' : alert.severity >= 3 ? 'warning' : 'info'}`;
alertDiv.innerHTML = `
<div class="time">${new Date().toLocaleTimeString()}</div>
<div class="message"><strong>${alert.atm_id}:</strong> ${alert.message}</div>
`;
alertsList.insertBefore(alertDiv, alertsList.firstChild);
// Keep only last 50 alerts
while (alertsList.children.length > 50) {
alertsList.removeChild(alertsList.lastChild);
}
}
// Connect on load
connectWebSocket();
// Fetch initial data
async function fetchStats() {
try {
const response = await fetch('/health');
const data = await response.json();
console.log('System health:', data);
} catch (error) {
console.error('Failed to fetch stats:', error);
}
}
fetchStats();
</script>
</body>
</html>
@@ -0,0 +1,225 @@
"""
ATM Image Preprocessing Pipeline
Preprocesses raw ATM images for anomaly detection training:
- Resize to model input size
- Normalize pixel values
- Augment training data
- Generate annotations in YOLO format
"""
import os
import cv2
import numpy as np
from pathlib import Path
from typing import Tuple, List, Dict
import json
import hashlib
from datetime import datetime
class ATMPreprocessor:
def __init__(self, config: Dict):
self.input_size = config.get('input_size', (640, 640))
self.normalize = config.get('normalize', True)
self.augment = config.get('augment', True)
def load_image(self, path: str) -> np.ndarray:
"""Load image from path."""
image = cv2.imread(path)
if image is None:
raise ValueError(f"Could not load image: {path}")
return cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
def resize(self, image: np.ndarray) -> np.ndarray:
"""Resize image to model input size."""
return cv2.resize(image, self.input_size, interpolation=cv2.INTER_LINEAR)
def normalize_image(self, image: np.ndarray) -> np.ndarray:
"""Normalize pixel values to [0, 1]."""
return image.astype(np.float32) / 255.0
def augment_image(self, image: np.ndarray) -> List[np.ndarray]:
"""Apply data augmentation."""
if not self.augment:
return [image]
augmented = [image]
# Horizontal flip
augmented.append(cv2.flip(image, 1))
# Brightness variations
augmented.append(np.clip(image * 1.2, 0, 255).astype(np.uint8))
augmented.append(np.clip(image * 0.8, 0, 255).astype(np.uint8))
# Slight rotation
h, w = image.shape[:2]
center = (w // 2, h // 2)
for angle in [-5, 5]:
M = cv2.getRotationMatrix2D(center, angle, 1.0)
rotated = cv2.warpAffine(image, M, (w, h), borderMode=cv2.BORDER_REFLECT)
augmented.append(rotated)
return augmented
def compute_hash(self, image: np.ndarray) -> str:
"""Compute SHA-256 hash for deduplication."""
return hashlib.sha256(image.tobytes()).hexdigest()
def compute_blur_score(self, image: np.ndarray) -> float:
"""Compute Laplacian variance as blur metric."""
gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
return cv2.Laplacian(gray, cv2.CV_64F).var()
def process_image(self, image_path: str, output_dir: str) -> Dict:
"""Process single image through full pipeline."""
# Load
image = self.load_image(image_path)
original_shape = image.shape
# Resize
resized = self.resize(image)
# Normalize
if self.normalize:
processed = self.normalize_image(resized)
else:
processed = resized
# Compute metrics
blur_score = self.compute_blur_score(resized)
image_hash = self.compute_hash(resized)
# Save processed image
filename = Path(image_path).stem
output_path = os.path.join(output_dir, f"{filename}.jpg")
cv2.imwrite(output_path, cv2.cvtColor(resized, cv2.COLOR_RGB2BGR))
return {
'original_path': image_path,
'output_path': output_path,
'original_shape': original_shape,
'processed_shape': resized.shape,
'blur_score': blur_score,
'image_hash': image_hash,
'processed_at': datetime.now().isoformat()
}
def process_directory(self, input_dir: str, output_dir: str) -> List[Dict]:
"""Process all images in directory."""
os.makedirs(output_dir, exist_ok=True)
results = []
for ext in ['*.jpg', '*.jpeg', '*.png']:
for image_path in Path(input_dir).glob(ext):
try:
result = self.process_image(str(image_path), output_dir)
results.append(result)
print(f"Processed: {image_path.name}")
except Exception as e:
print(f"Error processing {image_path}: {e}")
return results
def create_yolo_annotation(
image_path: str,
annotations: List[Dict],
output_path: str
):
"""
Create YOLO format annotation file.
Args:
image_path: Path to image
annotations: List of dicts with 'class_id', 'x_center', 'y_center', 'width', 'height'
output_path: Where to save .txt annotation
"""
with open(output_path, 'w') as f:
for ann in annotations:
line = f"{ann['class_id']} {ann['x_center']} {ann['y_center']} {ann['width']} {ann['height']}\n"
f.write(line)
def split_dataset(
data_dir: str,
output_dir: str,
train_ratio: float = 0.7,
val_ratio: float = 0.2,
test_ratio: float = 0.1
):
"""
Split dataset into train/val/test sets.
Args:
data_dir: Directory with images and annotations
output_dir: Where to create splits
train_ratio: Fraction for training
val_ratio: Fraction for validation
test_ratio: Fraction for testing
"""
import shutil
from sklearn.model_selection import train_test_split
# Get all image files
images = list(Path(data_dir).glob('*.jpg')) + list(Path(data_dir).glob('*.png'))
image_names = [img.stem for img in images]
# Split
train_names, temp_names = train_test_split(
image_names, test_size=(1 - train_ratio), random_state=42
)
val_names, test_names = train_test_split(
temp_names, test_size=(test_ratio / (val_ratio + test_ratio)), random_state=42
)
# Create directories
splits = {
'train': train_names,
'val': val_names,
'test': test_names
}
for split_name, names in splits.items():
split_dir = os.path.join(output_dir, split_name)
os.makedirs(split_dir, exist_ok=True)
os.makedirs(os.path.join(split_dir, 'images'), exist_ok=True)
os.makedirs(os.path.join(split_dir, 'labels'), exist_ok=True)
for name in names:
# Copy image
for ext in ['.jpg', '.png']:
src_img = os.path.join(data_dir, f"{name}{ext}")
if os.path.exists(src_img):
shutil.copy2(src_img, os.path.join(split_dir, 'images', f"{name}{ext}"))
break
# Copy annotation if exists
src_label = os.path.join(data_dir, f"{name}.txt")
if os.path.exists(src_label):
shutil.copy2(src_label, os.path.join(split_dir, 'labels', f"{name}.txt"))
print(f"{split_name}: {len(names)} images")
if __name__ == "__main__":
# Example usage
config = {
'input_size': (640, 640),
'normalize': True,
'augment': True
}
preprocessor = ATMPreprocessor(config)
# Process raw images
results = preprocessor.process_directory(
input_dir="data/raw",
output_dir="data/processed"
)
print(f"Processed {len(results)} images")
# Save metadata
with open("data/processed/metadata.json", "w") as f:
json.dump(results, f, indent=2)
@@ -0,0 +1,248 @@
"""
ATM Anomaly Detection Inference Pipeline
Run anomaly detection on ATM images and store results in database.
"""
import os
import sys
import argparse
import json
from pathlib import Path
from datetime import datetime
from typing import List, Dict, Optional
# Add parent to path
sys.path.append(str(Path(__file__).parent.parent))
from models.anomaly_detector import ATMAnomalyDetector
class ATMPredictor:
"""
Production inference pipeline for ATM anomaly detection.
"""
def __init__(
self,
model_path: str,
conf_threshold: float = 0.25,
db_connection: Optional[str] = None
):
"""
Initialize predictor.
Args:
model_path: Path to trained model
conf_threshold: Detection confidence threshold
db_connection: Database connection string (optional)
"""
self.detector = ATMAnomalyDetector(
model_path=model_path,
conf_threshold=conf_threshold
)
self.db_connection = db_connection
def predict_image(
self,
image_path: str,
atm_id: Optional[str] = None,
camera_angle: Optional[str] = None,
save_results: bool = True,
output_dir: str = 'output'
) -> Dict:
"""
Run prediction on single image.
Args:
image_path: Path to image
atm_id: ATM identifier
camera_angle: Camera angle (front, side, etc.)
save_results: Whether to save annotated image
output_dir: Output directory
Returns:
Prediction result dict
"""
# Run detection
detections = self.detector.predict(
image_path,
save=save_results,
save_dir=output_dir
)
# Build result
result = {
'image_path': image_path,
'atm_id': atm_id,
'camera_angle': camera_angle,
'timestamp': datetime.now().isoformat(),
'model_version': self.detector.model.ckpt_path if hasattr(self.detector.model, 'ckpt_path') else 'unknown',
'detections': detections,
'summary': {
'total_anomalies': len(detections),
'max_severity': max([d['severity'] for d in detections]) if detections else 0,
'requires_action': any(d['requires_action'] for d in detections),
'anomaly_types': list(set(d['class_name'] for d in detections))
}
}
# Save JSON result
if save_results:
os.makedirs(output_dir, exist_ok=True)
filename = Path(image_path).stem
result_path = os.path.join(output_dir, f"{filename}_result.json")
with open(result_path, 'w') as f:
json.dump(result, f, indent=2)
return result
def predict_directory(
self,
input_dir: str,
output_dir: str = 'output',
pattern: str = '*.jpg'
) -> List[Dict]:
"""
Run prediction on all images in directory.
Args:
input_dir: Input directory
output_dir: Output directory
pattern: File pattern to match
Returns:
List of prediction results
"""
image_paths = list(Path(input_dir).glob(pattern))
image_paths += list(Path(input_dir).glob(pattern.replace('jpg', 'png')))
results = []
for img_path in image_paths:
# Try to extract ATM ID from filename
# Expected format: {atm_id}_{timestamp}_{angle}.jpg
filename = img_path.stem
parts = filename.split('_')
atm_id = parts[0] if len(parts) > 0 else None
camera_angle = parts[-1] if len(parts) > 2 else None
result = self.predict_image(
str(img_path),
atm_id=atm_id,
camera_angle=camera_angle,
output_dir=output_dir
)
results.append(result)
print(f"Processed {img_path.name}: {result['summary']['total_anomalies']} anomalies")
# Save batch summary
summary = {
'total_images': len(results),
'total_anomalies': sum(r['summary']['total_anomalies'] for r in results),
'images_with_anomalies': sum(1 for r in results if r['summary']['total_anomalies'] > 0),
'max_severity_found': max((r['summary']['max_severity'] for r in results), default=0),
'processing_timestamp': datetime.now().isoformat()
}
summary_path = os.path.join(output_dir, 'batch_summary.json')
with open(summary_path, 'w') as f:
json.dump(summary, f, indent=2)
print(f"\nBatch complete: {summary['images_with_anomalies']}/{summary['total_images']} images have anomalies")
return results
def generate_alert(self, result: Dict) -> Optional[Dict]:
"""
Generate alert for high-severity detections.
Args:
result: Prediction result
Returns:
Alert dict or None if no alert needed
"""
if not result['summary']['requires_action']:
return None
critical = [d for d in result['detections'] if d['severity'] >= 4]
alert = {
'alert_id': f"ATM-{result['atm_id']}-{datetime.now().strftime('%Y%m%d%H%M%S')}",
'atm_id': result['atm_id'],
'timestamp': result['timestamp'],
'severity': result['summary']['max_severity'],
'anomalies': critical,
'image_path': result['image_path'],
'recommended_action': self._get_recommended_action(critical),
'status': 'new'
}
return alert
def _get_recommended_action(self, anomalies: List[Dict]) -> str:
"""Get recommended action based on anomalies."""
types = [a['class_name'] for a in anomalies]
if 'skimming_device' in types or 'suspicious_attachment' in types:
return "IMMEDIATE: Dispatch security team. Do not allow customer use."
elif 'physical_damage' in types or 'vandalism' in types:
return "URGENT: Schedule repair. Assess security camera footage."
elif 'out_of_service' in types:
return "Schedule maintenance visit. Check error logs remotely."
elif 'screen_damage' in types:
return "Schedule screen replacement. Consider temporary closure."
else:
return "Schedule routine maintenance. Monitor for escalation."
def main():
parser = argparse.ArgumentParser(description='ATM Anomaly Detection')
parser.add_argument('--model', required=True, help='Path to trained model')
parser.add_argument('--input', required=True, help='Input image or directory')
parser.add_argument('--output', default='output', help='Output directory')
parser.add_argument('--conf', type=float, default=0.25, help='Confidence threshold')
parser.add_argument('--save', action='store_true', help='Save annotated images')
args = parser.parse_args()
# Initialize predictor
predictor = ATMPredictor(
model_path=args.model,
conf_threshold=args.conf
)
# Run prediction
if os.path.isfile(args.input):
result = predictor.predict_image(
args.input,
save_results=args.save,
output_dir=args.output
)
print(f"\nResults for {args.input}:")
print(f" Anomalies found: {result['summary']['total_anomalies']}")
print(f" Max severity: {result['summary']['max_severity']}")
for det in result['detections']:
print(f" - {det['class_name']}: {det['confidence']:.2f} (severity {det['severity']})")
# Generate alert if needed
alert = predictor.generate_alert(result)
if alert:
print(f"\nALERT GENERATED: {alert['recommended_action']}")
elif os.path.isdir(args.input):
results = predictor.predict_directory(
args.input,
output_dir=args.output
)
else:
print(f"Error: {args.input} is not a valid file or directory")
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,337 @@
"""
ATM Anomaly Detection Model
YOLOv8-based anomaly detector for ATM images.
Detects: damage, graffiti, obstruction, skimming devices, out-of-service
"""
import torch
import torch.nn as nn
from ultralytics import YOLO
from pathlib import Path
from typing import List, Dict, Tuple, Optional
import numpy as np
import cv2
class ATMAnomalyDetector:
"""
ATM Anomaly Detection using YOLOv8.
Usage:
detector = ATMAnomalyDetector(model_path='models/best.pt')
results = detector.predict('path/to/image.jpg')
"""
# Anomaly class mapping
CLASS_NAMES = {
0: 'physical_damage',
1: 'vandalism',
2: 'graffiti',
3: 'dirt_debris',
4: 'obstruction',
5: 'skimming_device',
6: 'suspicious_attachment',
7: 'out_of_service',
8: 'screen_damage',
9: 'cash_jam',
10: 'receipt_jam',
11: 'lighting_failure',
12: 'camera_blind',
13: 'network_down'
}
SEVERITY_MAP = {
'skimming_device': 5,
'suspicious_attachment': 5,
'physical_damage': 4,
'vandalism': 4,
'camera_blind': 4,
'obstruction': 3,
'out_of_service': 3,
'screen_damage': 3,
'cash_jam': 3,
'lighting_failure': 3,
'network_down': 3,
'graffiti': 2,
'dirt_debris': 2,
'receipt_jam': 2
}
def __init__(
self,
model_path: Optional[str] = None,
conf_threshold: float = 0.25,
iou_threshold: float = 0.45,
device: str = 'auto'
):
"""
Initialize detector.
Args:
model_path: Path to trained YOLO model
conf_threshold: Confidence threshold for detections
iou_threshold: IoU threshold for NMS
device: 'cpu', 'cuda', or 'auto'
"""
self.conf_threshold = conf_threshold
self.iou_threshold = iou_threshold
# Set device
if device == 'auto':
self.device = 'cuda' if torch.cuda.is_available() else 'cpu'
else:
self.device = device
# Load model
if model_path and Path(model_path).exists():
self.model = YOLO(model_path)
else:
# Load pretrained COCO model as base
print("No trained model found. Loading YOLOv8n pretrained...")
self.model = YOLO('yolov8n.pt')
self.model.to(self.device)
def predict(
self,
image_path: str,
save: bool = False,
save_dir: Optional[str] = None
) -> List[Dict]:
"""
Run anomaly detection on image.
Args:
image_path: Path to image file
save: Whether to save annotated image
save_dir: Directory to save annotations
Returns:
List of detection dicts with keys:
- class_id: int
- class_name: str
- confidence: float
- bbox: [x1, y1, x2, y2]
- severity: int
"""
# Run inference
results = self.model(
image_path,
conf=self.conf_threshold,
iou=self.iou_threshold,
device=self.device,
verbose=False
)
detections = []
for result in results:
boxes = result.boxes
if boxes is None:
continue
for box in boxes:
class_id = int(box.cls.item())
confidence = float(box.conf.item())
bbox = box.xyxy[0].cpu().numpy().tolist()
class_name = self.CLASS_NAMES.get(class_id, 'unknown')
severity = self.SEVERITY_MAP.get(class_name, 1)
detection = {
'class_id': class_id,
'class_name': class_name,
'confidence': round(confidence, 4),
'bbox': [round(x, 2) for x in bbox],
'severity': severity,
'requires_action': severity >= 4
}
detections.append(detection)
# Sort by severity (highest first)
detections.sort(key=lambda x: x['severity'], reverse=True)
# Save annotated image if requested
if save and save_dir:
self._save_annotated(image_path, detections, save_dir)
return detections
def predict_batch(
self,
image_paths: List[str],
batch_size: int = 8
) -> List[List[Dict]]:
"""
Run detection on batch of images.
Args:
image_paths: List of image paths
batch_size: Batch size for inference
Returns:
List of detection lists
"""
all_detections = []
for i in range(0, len(image_paths), batch_size):
batch = image_paths[i:i + batch_size]
results = self.model(
batch,
conf=self.conf_threshold,
iou=self.iou_threshold,
device=self.device,
verbose=False
)
for result in results:
detections = []
boxes = result.boxes
if boxes is not None:
for box in boxes:
class_id = int(box.cls.item())
confidence = float(box.conf.item())
bbox = box.xyxy[0].cpu().numpy().tolist()
class_name = self.CLASS_NAMES.get(class_id, 'unknown')
detections.append({
'class_id': class_id,
'class_name': class_name,
'confidence': round(confidence, 4),
'bbox': [round(x, 2) for x in bbox],
'severity': self.SEVERITY_MAP.get(class_name, 1),
'requires_action': self.SEVERITY_MAP.get(class_name, 1) >= 4
})
detections.sort(key=lambda x: x['severity'], reverse=True)
all_detections.append(detections)
return all_detections
def _save_annotated(
self,
image_path: str,
detections: List[Dict],
save_dir: str
):
"""Save annotated image with bounding boxes."""
import os
os.makedirs(save_dir, exist_ok=True)
image = cv2.imread(image_path)
for det in detections:
x1, y1, x2, y2 = map(int, det['bbox'])
color = (0, 0, 255) if det['severity'] >= 4 else (0, 165, 255)
cv2.rectangle(image, (x1, y1), (x2, y2), color, 2)
label = f"{det['class_name']} {det['confidence']:.2f}"
cv2.putText(
image, label, (x1, y1 - 10),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2
)
filename = Path(image_path).name
save_path = os.path.join(save_dir, f"annotated_{filename}")
cv2.imwrite(save_path, image)
def train(
self,
data_yaml: str,
epochs: int = 100,
batch_size: int = 16,
img_size: int = 640,
output_dir: str = 'models/checkpoints'
):
"""
Train model on custom dataset.
Args:
data_yaml: Path to data.yaml for YOLO
epochs: Number of training epochs
batch_size: Batch size
img_size: Input image size
output_dir: Where to save checkpoints
"""
self.model.train(
data=data_yaml,
epochs=epochs,
batch=batch_size,
imgsz=img_size,
project=output_dir,
name='atm_anomaly',
device=self.device
)
def export(
self,
format: str = 'onnx',
output_path: Optional[str] = None
):
"""
Export model to deployment format.
Args:
format: 'onnx', 'torchscript', 'openvino', 'engine'
output_path: Where to save exported model
"""
self.model.export(format=format)
if output_path:
import shutil
default_path = f"models/checkpoints/atm_anomaly/weights/best.{format}"
if Path(default_path).exists():
shutil.copy2(default_path, output_path)
print(f"Exported to {output_path}")
def create_data_yaml(
train_dir: str,
val_dir: str,
test_dir: Optional[str] = None,
class_names: Optional[List[str]] = None,
output_path: str = 'data/data.yaml'
):
"""
Create YOLO data.yaml configuration file.
Args:
train_dir: Path to train directory
val_dir: Path to validation directory
test_dir: Path to test directory (optional)
class_names: List of class names
output_path: Where to save yaml
"""
if class_names is None:
class_names = list(ATMAnomalyDetector.CLASS_NAMES.values())
import yaml
data = {
'path': str(Path(train_dir).parent),
'train': str(Path(train_dir).relative_to(Path(train_dir).parent)),
'val': str(Path(val_dir).relative_to(Path(val_dir).parent)),
'nc': len(class_names),
'names': class_names
}
if test_dir:
data['test'] = str(Path(test_dir).relative_to(Path(test_dir).parent))
with open(output_path, 'w') as f:
yaml.dump(data, f, default_flow_style=False)
print(f"Created {output_path}")
if __name__ == "__main__":
# Example usage
detector = ATMAnomalyDetector()
# Single image prediction
results = detector.predict('data/test/atm_001.jpg', save=True, save_dir='output')
print(f"Found {len(results)} anomalies")
for r in results:
print(f" - {r['class_name']}: {r['confidence']:.2f} (severity: {r['severity']})")
+111
View File
@@ -0,0 +1,111 @@
"""
ATM Anomaly Detection Training Script
Trains YOLOv8 model on annotated ATM images.
"""
import os
import sys
import yaml
import argparse
from pathlib import Path
from datetime import datetime
# Add parent to path
sys.path.append(str(Path(__file__).parent.parent))
from models.anomaly_detector import ATMAnomalyDetector
def load_config(config_path: str) -> dict:
"""Load training configuration from YAML."""
with open(config_path, 'r') as f:
return yaml.safe_load(f)
def train_model(config: dict):
"""
Train anomaly detection model.
Args:
config: Training configuration dict
"""
print("=" * 60)
print("ATM Anomaly Detection Training")
print("=" * 60)
print(f"Start time: {datetime.now().isoformat()}")
print(f"Base model: {config['model']['base']}")
print(f"Epochs: {config['training']['epochs']}")
print(f"Batch size: {config['training']['batch_size']}")
print(f"Image size: {config['training']['image_size']}")
print("=" * 60)
# Initialize detector with base model
detector = ATMAnomalyDetector(
model_path=config['model']['base'],
device=config['hardware']['device']
)
# Create data.yaml
data_config = {
'path': str(Path(config['data']['train']).parent.parent),
'train': str(Path(config['data']['train']).relative_to(Path(config['data']['train']).parent.parent)),
'val': str(Path(config['data']['val']).relative_to(Path(config['data']['val']).parent.parent)),
'nc': config['model']['classes'],
'names': config['data']['names']
}
if Path(config['data']['test']).exists():
data_config['test'] = str(Path(config['data']['test']).relative_to(Path(config['data']['test']).parent.parent))
data_yaml_path = 'data/data.yaml'
os.makedirs('data', exist_ok=True)
with open(data_yaml_path, 'w') as f:
yaml.dump(data_config, f, default_flow_style=False)
print(f"\nData config saved to {data_yaml_path}")
print(f"Training samples: {count_samples(config['data']['train'])}")
print(f"Validation samples: {count_samples(config['data']['val'])}")
# Train
detector.train(
data_yaml=data_yaml_path,
epochs=config['training']['epochs'],
batch_size=config['training']['batch_size'],
img_size=config['training']['image_size'],
output_dir=config['output']['checkpoint_dir']
)
print("\n" + "=" * 60)
print("Training complete!")
print(f"End time: {datetime.now().isoformat()}")
print("=" * 60)
def count_samples(data_dir: str) -> int:
"""Count number of images in directory."""
if not Path(data_dir).exists():
return 0
return len(list(Path(data_dir).glob('**/*.jpg'))) + len(list(Path(data_dir).glob('**/*.png')))
def main():
parser = argparse.ArgumentParser(description='Train ATM Anomaly Detection Model')
parser.add_argument('--config', default='config/training.yaml', help='Training config file')
parser.add_argument('--resume', type=str, help='Resume from checkpoint')
args = parser.parse_args()
# Load config
config = load_config(args.config)
# Override with resume if provided
if args.resume:
config['model']['base'] = args.resume
# Train
train_model(config)
if __name__ == "__main__":
main()
Binary file not shown.