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