diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6ae2682 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.12"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + # Ultralytics varsayilan olarak CUDA'li torch cekiyor (~2.5 GB). CI'da GPU + # olmadigi icin once CPU tekerlegini kuruyoruz; ayni surumu bulan pip + # sonraki adimda tekrar indirmiyor. + - name: Install CPU-only PyTorch + run: | + python -m pip install -U pip + python -m pip install torch --index-url https://download.pytorch.org/whl/cpu + + - name: Install package with dev extras + run: python -m pip install -e ".[dev]" + + - name: Run test suite + run: pytest tests/ -v diff --git a/README.md b/README.md index 6951a98..5827879 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,58 @@ -# RoomGate AI ๐ชโก +# RoomGate AI -[](https://www.python.org/) -[](https://github.com/ultralytics/ultralytics) -[](LICENSE) -[]() -[](https://github.com/psf/black) - -**RoomGate AI** is an enterprise-grade, high-performance Smart Access & Occupancy Monitoring System engineered for real-time computer vision processing, spatial region-of-interest (ROI) filtering, and automated hardware access control. - -Designed for high-throughput operational environments, RoomGate AI features a multi-threaded video stream pipeline, non-blocking asynchronous event logging, an intuitive glassmorphic visual HUD, and embedded web streaming capabilities. - ---- +Occupancy-aware door control. A YOLO detector counts people inside a defined +region of the frame, a debounced decision engine turns that count into an +allow/deny verdict, and a serial relay acts on it โ with the firmware +auto-relocking so a software failure cannot leave a door open. -## ๐ Key Architecture & Capabilities - -- โก **High-Throughput Threaded Acquisition**: Dedicated background `ThreadedCamera` pipeline ensuring non-blocking video capture and zero I/O frame drops. -- ๐ฏ **Spatial Region-of-Interest (ROI) Engine**: Real-time geometric polygon containment checks with configurable spatial boundaries. -- ๐ **Deterministic Decision Engine**: Temporal median filtering and confidence thresholding to guarantee stable, chatter-free access control decisions. -- ๐ ๏ธ **Hardware Lock Integration**: Direct serial protocol interfacing with Arduino/ESP32 relay modules, dry-run safety modes, and fail-safe command throttling. -- ๐ **Asynchronous SQLite WAL Event Logging**: Non-blocking SQLite event persistence utilizing Write-Ahead Logging (WAL), indexed analytical schemas, and image snapshot archiving. -- ๐ฅ๏ธ **Glassmorphic Operational Visual HUD**: Real-time telemetry overlay featuring dynamic access indicators, live FPS metrics, inference latency timing, and bounding box spatial tracking. -- ๐ **Embedded Web Dashboard & MJPEG Stream**: Integrated HTTP web dashboard serving a real-time MJPEG video feed and JSON status REST API for remote administrative monitoring. -- ๐ **Performance Diagnostic Tools**: Command-line benchmark and diagnostic suite for evaluating hardware acceleration, frame throughput, and sub-millisecond latency. +[](https://github.com/emrefbulut/RoomGate-AI/actions/workflows/ci.yml) +[](https://www.python.org/) +[](https://github.com/ultralytics/ultralytics) +[](LICENSE) --- -## ๐๏ธ System Architecture +## Pipeline ```mermaid flowchart TD - A[Camera / Video Source] -->|Raw Frames| B[ThreadedCapture Pipeline] - B -->|Latest Frame| C[YOLO Spatial Detector & Tracker] - C -->|Detections| D[ROI Spatial Filter] - D -->|Inside Count| E[Occupancy Decision Engine] - E -->|Access Command| F[Hardware Relay Controller] - E -->|Event Record| G[Async SQLite WAL Logger] - C -->|Annotated Stream| H[Glassmorphic HUD Renderer] - H -->|Frame Render| I[OpenCV Window & Web MJPEG Server] + A["Camera / video source"] -->|"threaded capture"| B["Latest frame"] + B --> C["YOLO26 detectorCUDA ยท TensorRT ยท ONNX ยท OpenVINO"] + C --> D["ROI polygon filter"] + D --> E["Decision enginemedian over N frames"] + E -->|"OPEN / DENY"| F["Relay controllerserial, throttled"] + E --> G["Async SQLite WAL logger"] + C --> H["HUD renderer"] + H --> I["OpenCV window"] + H --> J["MJPEG dashboard"] + F -->|"auto-relock 3 s"| K["Arduino / ESP32 lock"] ``` --- -## ๐ Installation & Setup +## Design decisions worth knowing + +**The decision engine fails safe.** If inference throws, the frame is not +silently treated as "nobody is here" โ that would open the door on a model or +camera failure. Detection errors produce an explicit `DENY` with reason +`detection_unavailable`. + +**Two independent debounces.** Occupancy is the median over the last N frames, +so a single dropped or spurious detection cannot flip the verdict. Separately, +the relay refuses to repeat the same command inside a minimum interval. -### Prerequisites -- Python 3.10+ -- OpenCV 4.8+ -- PyTorch 2.0+ +**The firmware does not trust the host.** `arduino_relay_lock.ino` re-locks +3 seconds after any `OPEN`, so a crashed or disconnected controller cannot leave +the door unlocked. The software layer is an optimisation, not the safety +boundary. -### Environment Installation +**Counters track transitions, not frames.** `total_grants` increments when the +verdict changes, not once per frame โ at 30 FPS the latter would just be a +frame counter wearing a meaningful name. + +--- + +## Install ```powershell python -m venv .venv @@ -57,113 +61,118 @@ python -m pip install -U pip python -m pip install -e . ``` ---- - -## ๐ป Usage & CLI Reference +Requires Python 3.10+, OpenCV 4.8+, and PyTorch 2.0+ (pulled in by Ultralytics). -### 1. Live Monitoring Mode +--- -Launch the live access monitor using default system configuration: +## Usage ```powershell roomgate-ai monitor --config config/default.yaml ``` -To run in simulation mode (dry-run hardware relay with synthetic camera feed): +Explore without a camera or a relay: ```powershell roomgate-ai monitor --dry-run ``` -To enable the live **Web Dashboard** on port `8080`: - -```powershell -roomgate-ai monitor --web --web-port 8080 -``` -*Access the live stream dashboard at `http://localhost:8080`.* +| Command | Purpose | +| :--- | :--- | +| `roomgate-ai monitor` | Live detection, decision, relay, HUD | +| `roomgate-ai web` | Headless MJPEG dashboard and status API | +| `roomgate-ai status` | Runtime environment and CUDA/MPS diagnostics | +| `roomgate-ai benchmark --frames 100` | Throughput and inference latency | +| `roomgate-ai collect` | Capture raw images for a dataset | +| `roomgate-ai dataset-report` | Validate dataset structure and labels | +| `roomgate-ai make-dataset-yaml` | Generate the Ultralytics dataset descriptor | +| `roomgate-ai train` | Fine-tune on a custom dataset | +| `roomgate-ai export` | Export to ONNX / TensorRT / OpenVINO | --- -### 2. Diagnostics & Performance Benchmarks - -Run hardware acceleration diagnostics (CUDA / MPS / CPU availability): +## Web dashboard ```powershell -roomgate-ai status +roomgate-ai monitor --web --web-port 8080 ``` -Execute latency and frame rate benchmark tests: +> **Security.** The dashboard streams a live camera feed and occupancy data. It +> binds to `127.0.0.1` by default. To reach it from another machine, set +> `web.host` โ and set `web.access_token` at the same time, otherwise anyone on +> the network can watch the room. With a token configured, open +> `http://:8080/?token=`. -```powershell -roomgate-ai benchmark --frames 100 -``` +The token is accepted as `Authorization: Bearer ` or as a `?token=` +query parameter; the query form exists because `` and MJPEG streams cannot +send custom headers. --- -### 3. Full Subcommand Reference +## Configuration -| Subcommand | Description | -| :--- | :--- | -| `roomgate-ai monitor` | Executes the real-time access monitoring pipeline | -| `roomgate-ai web` | Runs the headless web dashboard and MJPEG stream server | -| `roomgate-ai benchmark` | Evaluates system throughput (FPS) and latency metrics | -| `roomgate-ai status` | Displays runtime software environment and CUDA diagnostics | -| `roomgate-ai collect` | Collects raw image datasets for specific operational scenarios | -| `roomgate-ai dataset-report` | Validates dataset structure and label completeness | -| `roomgate-ai train` | Fine-tunes object detection models on custom datasets | -| `roomgate-ai export` | Exports trained models to ONNX or TensorRT formats | - ---- - -## โ๏ธ Configuration Schema (`config/default.yaml`) +Full reference: [`config/default.yaml`](config/default.yaml). ```yaml camera: - source: 0 + source: 0 # index, file path, or RTSP URL width: 1280 height: 720 - fps: 30 - threaded: true + threaded: true # decouple capture from inference + buffer_size: 1 # keep only the newest frame model: weights: "yolo26n.pt" confidence: 0.35 iou: 0.7 - device: null + device: null # null = auto-select CUDA / MPS / CPU + tracker: "bytetrack.yaml" + frame_skip: 0 # reuse detections for N frames on slow hardware + export_format: null # engine | onnx | openvino + auto_export: true + half_precision: false occupancy: max_occupancy: 3 - confirmation_frames: 5 + confirmation_frames: 5 # median window minimum_average_confidence: 0.35 - log_every_seconds: 1.0 roi: name: "main_room" - points: - - [80, 80] - - [1200, 80] - - [1200, 700] - - [80, 700] + points: [[80, 80], [1200, 80], [1200, 700], [80, 700]] relay: enabled: false port: "COM3" baudrate: 9600 + open_command: "OPEN" + lock_command: "LOCK" + deny_command: "DENY" + min_command_interval_seconds: 1.0 logging: sqlite_path: "logs/events.sqlite3" snapshot_dir: "snapshots" save_event_snapshots: false wal_mode: true + +web: + enabled: false + host: "127.0.0.1" # only widen together with access_token + port: 8080 + quality: 80 + access_token: null ``` --- -## ๐ Hardware Relay Protocol +## Hardware -RoomGate AI interfaces with hardware relays (Arduino, ESP32, or industrial controllers) via high-speed serial communication. +Wiring notes: [`docs/hardware_wiring_tr.md`](docs/hardware_wiring_tr.md). +Dataset strategy: [`docs/dataset_strategy_tr.md`](docs/dataset_strategy_tr.md). -Deploy firmware located at `hardware/arduino_relay_lock/arduino_relay_lock.ino` to your micro-controller board, then enable serial control in `config/default.yaml`: +Flash [`hardware/arduino_relay_lock/arduino_relay_lock.ino`](hardware/arduino_relay_lock/arduino_relay_lock.ino), +then enable the serial link: ```yaml relay: @@ -172,18 +181,27 @@ relay: baudrate: 9600 ``` ---- +The sketch accepts `OPEN`, `LOCK`, and `DENY`, acknowledges each on the serial +line, and re-locks automatically after `OPEN_MS` (3 s by default). -## ๐งช Verification & Automated Testing +--- -Execute the unit test suite: +## Testing ```powershell +python -m pip install -e ".[dev]" pytest tests/ -v ``` +Covered: ROI containment, the decision engine including the fail-safe path and +transition counting, dashboard authorisation (bearer, query token, wrong token, +missing token, malformed scheme, prefix-only attempts), event logging, dataset +validation, config parsing, and relay throttling. + +CI runs the same suite on Python 3.10 and 3.12 for every push and pull request. + --- -## ๐ License +## License -This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for complete details. +MIT โ see [LICENSE](LICENSE). diff --git a/config/default.yaml b/config/default.yaml index fe38544..e27a248 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -48,6 +48,9 @@ logging: web: enabled: false - host: "0.0.0.0" + # Pano canli kamera goruntusu yayinlar. "0.0.0.0" yapip agdaki herkese + # acacaksaniz mutlaka bir access_token tanimlayin. + host: "127.0.0.1" port: 8080 quality: 80 + access_token: null diff --git a/pyproject.toml b/pyproject.toml index 96e644f..92b2017 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,11 @@ dependencies = [ "ultralytics", ] +[project.optional-dependencies] +dev = [ + "pytest>=8.0", +] + [project.scripts] roomgate-ai = "roomgate.cli:main" roomgate = "roomgate.cli:main" diff --git a/roomgate/cli.py b/roomgate/cli.py index 69a11cc..9c7a981 100644 --- a/roomgate/cli.py +++ b/roomgate/cli.py @@ -214,7 +214,11 @@ def main(argv: list[str] | None = None) -> int: web_dashboard = None if args.command == "web" or args.web: port = getattr(args, "port", None) or getattr(args, "web_port", 8080) - web_dashboard = RoomGateWebDashboard(host=settings.web.host, port=port) + web_dashboard = RoomGateWebDashboard( + host=settings.web.host, + port=port, + access_token=settings.web.access_token, + ) no_win = args.no_window or (args.command == "web") RoomGateMonitor(settings, detector, relay, logger_inst, web_dashboard).run(show=not no_win) diff --git a/roomgate/config.py b/roomgate/config.py index f1564a4..41c291b 100644 --- a/roomgate/config.py +++ b/roomgate/config.py @@ -74,9 +74,13 @@ class LoggingSettings: @dataclass(frozen=True) class WebSettings: enabled: bool = False - host: str = "0.0.0.0" + # Pano canli kamera goruntusu ve doluluk verisi yayinlar. Varsayilan olarak + # yalnizca yerel makineden erisilebilir; agdaki herkese acmak icin host + # degeri acikca degistirilmeli ve access_token tanimlanmalidir. + host: str = "127.0.0.1" port: int = 8080 quality: int = 80 + access_token: str | None = None @dataclass(frozen=True) @@ -175,8 +179,9 @@ def load_settings(path: str | Path = "config/default.yaml") -> AppSettings: ), web=WebSettings( enabled=bool(web.get("enabled", False)), - host=str(web.get("host", "0.0.0.0")), + host=str(web.get("host", "127.0.0.1")), port=int(web.get("port", 8080)), quality=int(web.get("quality", 80)), + access_token=(str(web["access_token"]) if web.get("access_token") else None), ), ) diff --git a/roomgate/decision.py b/roomgate/decision.py index 4f35fd0..b54cb49 100644 --- a/roomgate/decision.py +++ b/roomgate/decision.py @@ -30,11 +30,60 @@ def __init__( self.max_occupancy = max_occupancy self.minimum_average_confidence = minimum_average_confidence self._history: deque[int] = deque(maxlen=confirmation_frames) + self._last_allowed: bool | None = None self.peak_occupancy: int = 0 self.total_grants: int = 0 self.total_denials: int = 0 - def decide(self, occupancy: int, average_confidence: float) -> AccessDecision: + def _finalize( + self, + *, + occupancy: int, + stable_occupancy: int, + allowed: bool, + command: str, + reason: str, + average_confidence: float, + ) -> AccessDecision: + # Sayaclar kare basina degil, karar DEGISTIGINDE artar. Onceden her kare + # sayildigi icin "total_grants" 30 FPS'te saniyede 30 artiyor ve gercek + # erisim olayi sayisiyla hicbir ilgisi kalmiyordu. + if self._last_allowed is None or self._last_allowed != allowed: + if allowed: + self.total_grants += 1 + else: + self.total_denials += 1 + self._last_allowed = allowed + + return AccessDecision( + occupancy=occupancy, + stable_occupancy=stable_occupancy, + allowed=allowed, + command=command, + reason=reason, + average_confidence=average_confidence, + peak_occupancy=self.peak_occupancy, + ) + + def decide( + self, + occupancy: int, + average_confidence: float, + detection_available: bool = True, + ) -> AccessDecision: + # Erisim kontrolu fail-safe olmalidir. Model yuklenemedi, kamera karesi + # bos geldi ya da cikarim hata verdiyse "kimse yok" sonucuna varmak + # kapiyi acmak demektir; boyle bir durumda karar DENY olmalidir. + if not detection_available: + return self._finalize( + occupancy=occupancy, + stable_occupancy=self.max_occupancy, + allowed=False, + command="DENY", + reason="detection_unavailable", + average_confidence=average_confidence, + ) + current_occ = max(0, occupancy) self._history.append(current_occ) stable_occupancy = int(median(self._history)) @@ -43,36 +92,30 @@ def decide(self, occupancy: int, average_confidence: float) -> AccessDecision: self.peak_occupancy = stable_occupancy if stable_occupancy >= self.max_occupancy: - self.total_denials += 1 - return AccessDecision( + return self._finalize( occupancy=occupancy, stable_occupancy=stable_occupancy, allowed=False, command="DENY", reason="occupancy_limit_reached", average_confidence=average_confidence, - peak_occupancy=self.peak_occupancy, ) if occupancy > 0 and average_confidence < self.minimum_average_confidence: - self.total_denials += 1 - return AccessDecision( + return self._finalize( occupancy=occupancy, stable_occupancy=stable_occupancy, allowed=False, command="DENY", reason="low_detection_confidence", average_confidence=average_confidence, - peak_occupancy=self.peak_occupancy, ) - self.total_grants += 1 - return AccessDecision( + return self._finalize( occupancy=occupancy, stable_occupancy=stable_occupancy, allowed=True, command="OPEN", reason="below_occupancy_limit", average_confidence=average_confidence, - peak_occupancy=self.peak_occupancy, ) diff --git a/roomgate/monitor.py b/roomgate/monitor.py index 56283c7..df38fb7 100644 --- a/roomgate/monitor.py +++ b/roomgate/monitor.py @@ -107,12 +107,23 @@ def run( last_fps_t = now # Run AI detection - detections = self.detector.detect(frame) + detection_available = True + try: + detections = self.detector.detect(frame) + except Exception as exc: + # Cikarim hatasi "kimse yok" ile ayni sey degildir. Bunu + # sessizce bos listeye cevirmek kapiyi acardi. + logger.error(f"Detection failed, denying access for this frame: {exc}") + detections = [] + detection_available = False + inside = self.roi.filter_detections(detections) avg_confidence = _average_confidence(inside) # Make access decision - decision = self.decision_engine.decide(len(inside), avg_confidence) + decision = self.decision_engine.decide( + len(inside), avg_confidence, detection_available=detection_available + ) self.relay.send(decision.command) # Async log decision event diff --git a/roomgate/web.py b/roomgate/web.py index 54e0d57..a595fb3 100644 --- a/roomgate/web.py +++ b/roomgate/web.py @@ -1,11 +1,12 @@ from __future__ import annotations -from http.server import BaseHTTPRequestHandler, HTTPServer +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import hmac import json import logging -import socketserver import threading import time +from urllib.parse import urlparse, parse_qs from typing import Any import cv2 @@ -18,6 +19,7 @@ class StreamState: def __init__(self) -> None: self.lock = threading.Lock() self.latest_jpeg: bytes | None = None + self.frame_version: int = 0 self.status_data: dict[str, Any] = { "system": "RoomGate AI", "status": "INITIALIZING", @@ -32,6 +34,7 @@ def update_frame(self, frame: np.ndarray, quality: int = 80) -> None: if ok: with self.lock: self.latest_jpeg = encoded.tobytes() + self.frame_version += 1 def update_status(self, data: dict[str, Any]) -> None: with self.lock: @@ -39,14 +42,56 @@ def update_status(self, data: dict[str, Any]) -> None: self.status_data["timestamp"] = time.time() +def is_request_authorized( + expected_token: str | None, + authorization_header: str | None, + request_path: str, +) -> bool: + """Bir erisim anahtari tanimlanmissa istegi dogrular. + + Canli kamera goruntusu ve doluluk verisi hassas oldugu icin, pano localhost + disina acildiginda anahtar zorunlu hale getirilebilir. Anahtar ya + `Authorization: Bearer ` basligiyla ya da `?token=` sorgu + parametresiyle verilir; ikincisi sart, cunku ve MJPEG akisi ozel + baslik gonderemez. + + Handler sinifindan ayri bir fonksiyon olarak duruyor: guvenlik karari + dogrudan test edilebilsin diye. + """ + if not expected_token: + return True + + header = authorization_header or "" + if header.startswith("Bearer "): + if hmac.compare_digest(header[len("Bearer ") :], expected_token): + return True + + supplied = parse_qs(urlparse(request_path).query).get("token", [""])[0] + return hmac.compare_digest(supplied, expected_token) + + class RoomGateWebHandler(BaseHTTPRequestHandler): state: StreamState | None = None + access_token: str | None = None def log_message(self, format: str, *args: Any) -> None: pass # Suppress default server access logging to reduce terminal clutter + def _is_authorized(self) -> bool: + return is_request_authorized( + self.access_token, + self.headers.get("Authorization"), + self.path, + ) + def do_GET(self) -> None: - if self.path in ("/", "/index.html"): + if not self._is_authorized(): + self.send_error(401, "Unauthorized") + return + + route = urlparse(self.path).path + + if route in ("/", "/index.html"): self.send_response(200) self.send_header("Content-type", "text/html; charset=utf-8") self.end_headers() @@ -73,7 +118,7 @@ def do_GET(self) -> None: RoomGate AI AI-Powered Smart Access & Occupancy Live Dashboard - + StatusALLOW @@ -81,9 +126,14 @@ def do_GET(self) -> None: FPS0.0
AI-Powered Smart Access & Occupancy Live Dashboard