From 79873d79f76c2c7d05a701785b2a6ec22322717f Mon Sep 17 00:00:00 2001 From: kassam Date: Sun, 12 Apr 2026 19:05:32 +0400 Subject: [PATCH] Initial project commit --- .gitignore | 10 + Config/logging.json | 11 + DEPLOY.md | 489 ++++++++++++++++++++++ deploy.sh | 129 ++++++ detect.py | 147 +++++++ gui.py | 531 ++++++++++++++++++++++++ logger.py | 63 +++ manager.py | 437 ++++++++++++++++++++ requirements.txt | 5 + run_local.sh | 110 +++++ run_robot.sh | 116 ++++++ saqr.py | 909 +++++++++++++++++++++++++++++++++++++++++ saqr_g1_bridge.py | 376 +++++++++++++++++ train.py | 118 ++++++ use case catalogue.pdf | Bin 0 -> 140482 bytes view_stream.py | 48 +++ 16 files changed, 3499 insertions(+) create mode 100644 .gitignore create mode 100644 Config/logging.json create mode 100644 DEPLOY.md create mode 100755 deploy.sh create mode 100644 detect.py create mode 100644 gui.py create mode 100644 logger.py create mode 100644 manager.py create mode 100644 requirements.txt create mode 100755 run_local.sh create mode 100755 run_robot.sh create mode 100644 saqr.py create mode 100644 saqr_g1_bridge.py create mode 100644 train.py create mode 100644 use case catalogue.pdf create mode 100644 view_stream.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..77fb19b --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +dataset/ +runs/ +models/ +captures/ +Logs/ +__pycache__/ +*.pyc +*.pt +*.pth +*.log diff --git a/Config/logging.json b/Config/logging.json new file mode 100644 index 0000000..3ff6ad5 --- /dev/null +++ b/Config/logging.json @@ -0,0 +1,11 @@ +{ + "level": "INFO", + "format": "%(asctime)s | %(name)s | %(levelname)s | %(message)s", + "file": true, + "console": true, + "categories": { + "Training": "INFO", + "Inference": "INFO", + "Manager": "INFO" + } +} diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..26a191e --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,489 @@ +# Saqr PPE Detection - Deployment Guide +## Unitree G1 Robot + Intel RealSense D435I + +--- + +## Robot Details + +| Item | Value | +|------|-------| +| Robot | Unitree G1 Humanoid | +| IP | `192.168.123.164` | +| User | `unitree` | +| OS | Ubuntu 20.04 (aarch64 / Jetson) | +| Python | 3.10 (conda env: `teleimager`) | +| Camera | Intel RealSense D435I | +| Serial | `243622073459` | +| Port | USB 3.2 @ `/dev/video0` | + +--- + +## Step 1: Train the Model (Dev Machine) + +```bash +cd ~/Robotics_workspace/AI/Saqr +conda activate AI_MSI_yolo +python train.py --dataset dataset --epochs 100 --batch 16 +``` + +Verify model exists: +```bash +ls -lh models/saqr_best.pt +# Expected: ~5.3 MB +``` + +--- + +## Step 2: Deploy to Robot (Dev Machine) + +### Option A: Auto deploy +```bash +cd ~/Robotics_workspace/AI/Saqr +./deploy.sh +``` + +### Option B: Manual SCP +```bash +# Create folders +ssh unitree@192.168.123.164 "mkdir -p ~/Saqr/{models,captures/{SAFE,PARTIAL,UNSAFE},Config,Logs}" + +# Copy project files +scp saqr.py saqr_g1_bridge.py detect.py manager.py logger.py gui.py requirements.txt deploy.sh DEPLOY.md \ + unitree@192.168.123.164:~/Saqr/ + +# Copy config +scp Config/logging.json unitree@192.168.123.164:~/Saqr/Config/ + +# Copy trained model (5.3 MB) +scp models/saqr_best.pt unitree@192.168.123.164:~/Saqr/models/ +``` + +--- + +## Step 3: Install Dependencies (Robot) + +```bash +ssh unitree@192.168.123.164 +``` + +### Fix system clock (required for SSL/pip): +```bash +sudo date -s "2026-04-10 15:00:00" +``` + +### Install into teleimager conda env: +```bash +conda activate teleimager +python -m pip install ultralytics opencv-python-headless numpy PyYAML +``` + +If pip fails (SSL errors), install offline from dev machine: +```bash +# On dev machine: +mkdir -p /tmp/saqr_pkgs +pip download ultralytics opencv-python-headless numpy PyYAML \ + -d /tmp/saqr_pkgs --python-version 3.10 --platform manylinux2014_aarch64 --only-binary=:all: +scp -r /tmp/saqr_pkgs unitree@192.168.123.164:/tmp/saqr_pkgs + +# On robot: +conda activate teleimager +python -m pip install --no-index --find-links=/tmp/saqr_pkgs ultralytics opencv-python-headless numpy PyYAML +``` + +### Install Jetson GPU PyTorch (for CUDA acceleration): +```bash +# Remove pip PyTorch (wrong CUDA version) +python -m pip uninstall torch torchvision -y + +# Install Jetson-specific PyTorch for JetPack 5.1 / CUDA 11.4 +python -m pip install --no-cache-dir \ + https://developer.download.nvidia.com/compute/redist/jp/v51/pytorch/torch-2.1.0a0+41361538.nv23.06-cp310-cp310-linux_aarch64.whl + +python -m pip install --no-cache-dir \ + https://developer.download.nvidia.com/compute/redist/jp/v51/pytorch/torchvision-0.16.1a0+5e8e2f1-cp310-cp310-linux_aarch64.whl +``` + +### Fix Qt / Display (choose one): + +**A) At the robot's physical terminal (monitor connected):** +```bash +xhost +local: +export DISPLAY=:0 +export QT_QPA_PLATFORM=xcb +``` + +**B) Via SSH with X11 forwarding:** +```bash +# From dev machine: +ssh -X unitree@192.168.123.164 +export QT_QPA_PLATFORM=xcb +``` + +**C) Headless / no display (SSH without -X):** +```bash +export QT_QPA_PLATFORM=offscreen +# Always add --headless flag when running saqr.py +``` + +**Make permanent:** +```bash +echo 'export QT_QPA_PLATFORM=offscreen' >> ~/.bashrc +source ~/.bashrc +``` + +**Common error:** `Invalid MIT-MAGIC-COOKIE-1 key` or `could not connect to display :0` +This means you're in SSH without X11 auth. Either use `ssh -X`, run `xhost +local:` on the physical terminal, or switch to headless mode. + +### Fix system clock (required for pip/SSL): +```bash +sudo date -s "2026-04-10 16:00:00" +``` + +### Verify install: +```bash +python -c "from ultralytics import YOLO; print('ultralytics OK')" +python -c "import torch; print('CUDA:', torch.cuda.is_available())" +python -c "import cv2; print('opencv OK')" +``` + +--- + +## Step 4: Run PPE Detection (Robot) + +### Option A: OpenCV + RealSense RGB (recommended, no pyrealsense2 needed): +```bash +conda activate teleimager +cd ~/Saqr + +# === WITH DISPLAY (physical monitor on robot) === +xhost + +export DISPLAY=:0 +python saqr.py --source /dev/video2 --model models/saqr_best.pt + +# === HEADLESS via SSH (no display, saves captures + CSV) === +export QT_QPA_PLATFORM=offscreen +python saqr.py --source /dev/video2 --model models/saqr_best.pt --headless +``` + +**Note:** `/dev/video2` is the RealSense D435I RGB camera accessed directly via OpenCV V4L2. +No pyrealsense2 SDK needed. Pure OpenCV frames (640x480 BGR). + +### Option B: RealSense SDK (pyrealsense2): +```bash +python saqr.py --source realsense --model models/saqr_best.pt --headless +python saqr.py --source realsense:243622073459 --model models/saqr_best.pt --headless +``` + +### Option C: GUI (dev machine only, not on robot): +```bash +# On your dev machine (not the robot): +python gui.py --source 0 --model models/saqr_best.pt +``` +**Note:** gui.py requires PySide6 and a display. It will NOT work on the headless Jetson robot. + +### With OpenCV camera index: +```bash +python saqr.py --source 0 --model models/saqr_best.pt --headless +``` + +### With V4L2 device path: +```bash +python saqr.py --source /dev/video0 --model models/saqr_best.pt --headless +``` + +### With GUI (if display connected): +```bash +python gui.py --source realsense --model models/saqr_best.pt +``` + +### Simple detection (no tracking): +```bash +python detect.py --source realsense --model models/saqr_best.pt +``` + +--- + +## Step 4b: Run with G1 TTS + Reject Action (Bridge) + +`saqr_g1_bridge.py` spawns `saqr.py`, parses its event stream, and drives the +G1 **onboard TTS** and the G1 **arm action client** on each per-person status +transition: + +| Transition | TTS (speaker_id=2, English) | Arm action | +|------------|------------------------------|------------| +| → UNSAFE | "Not safe! Please wear your protective equipment." | `reject` (id=13) + auto `release arm` | +| → SAFE | "Safe." | — | +| → PARTIAL | — | — | + +Requires `unitree_sdk2py` installed on the robot and a reachable DDS bus on +`eth0`. The bridge uses a single `ChannelFactoryInitialize` for both clients. + +### Headless + MJPEG stream (recommended over SSH): +```bash +conda activate marcus # or teleimager — whichever env has unitree_sdk2py +cd ~/Saqr +python3 saqr_g1_bridge.py --iface eth0 --source realsense --headless -- --stream 8080 +``` +Then open `http://192.168.123.164:8080` in your laptop browser. + +### With live OpenCV window (physical monitor on robot): +```bash +xhost +local: >/dev/null 2>&1 +DISPLAY=:0 python3 saqr_g1_bridge.py --iface eth0 --source realsense +``` +`q` in the window quits; Ctrl+C in the terminal is also forwarded to Saqr. + +### Dry run (no TTS, no motion — just see decisions): +```bash +python3 saqr_g1_bridge.py --dry-run --source realsense --headless +``` + +### Bridge CLI flags: + +| Flag | Default | Description | +|------|---------|-------------| +| `--iface` | *(default DDS)* | DDS network interface, e.g. `eth0` | +| `--timeout` | `10.0` | Arm/Audio client timeout (seconds) | +| `--cooldown` | `8.0` | Per-(id, status) seconds before re-triggering | +| `--release-after` | `2.0` | Seconds before auto `release arm` (0 = never) | +| `--speaker-id` | `2` | G1 `TtsMaker` speaker_id (2 = English on current firmware) | +| `--dry-run` | off | Parse events but never call the SDK | +| `--source` | — | Pass through to saqr (`0` / `realsense` / `/dev/video2` / path) | +| `--headless` | off | Pass `--headless` to saqr | +| `--saqr-conf` | — | Pass `--conf` to saqr | +| `--imgsz` | — | Pass `--imgsz` to saqr | +| `--device` | — | Pass `--device` to saqr (`cpu` / `0` / `cuda:0`) | +| `-- ` | — | Everything after `--` is forwarded raw to saqr | + +### Speaker-id reference + +speaker_ids are **locked to a language** — they do NOT auto-detect input text. +On current G1 firmware, `speaker_id=0` is Chinese regardless of what you feed +it. Speaker 2 was confirmed English by running Sanad mode 6 +(`voice_example.py 6`). If the robot's firmware changes, re-scan: +```bash +# On the robot (in a conda env with unitree_sdk2py): +python3 ~/Sanad/voice_example.py 6 +``` +and pass the new id with `--speaker-id N`. + +### What successful output looks like: +``` +[BRIDGE] G1ArmActionClient ready (iface=eth0) +[BRIDGE] G1 AudioClient ready (speaker_id=2) +[BRIDGE] launching: /.../python3 -u /home/unitree/Saqr/saqr.py --source realsense --headless +... +ID 0001 | NEW | SAFE | wearing: helmet, vest | missing: none | ... +[BRIDGE] tts -> 'Safe.' +ID 0002 | NEW | UNSAFE | wearing: none | missing: vest | ... +[BRIDGE] tts -> 'Not safe! Please wear your protective equipment.' +[BRIDGE] -> reject +[BRIDGE] -> release arm +``` + +--- + +## Step 5: Check Results (Robot) + +### Live status: +```bash +cat ~/Saqr/captures/result.csv +``` + +### Event history (audit log): +```bash +cat ~/Saqr/captures/events.csv +``` + +### Captured photos: +```bash +ls ~/Saqr/captures/SAFE/ +ls ~/Saqr/captures/PARTIAL/ +ls ~/Saqr/captures/UNSAFE/ +``` + +### Export CSV report: +```bash +cd ~/Saqr +python manager.py --export +``` + +### Download results to dev machine: +```bash +# From dev machine +scp -r unitree@192.168.123.164:~/Saqr/captures/ ./captures_from_robot/ +scp unitree@192.168.123.164:~/Saqr/captures/events.csv ./events_robot.csv +``` + +--- + +## Camera Source Options + +| Source | Command | Description | +|--------|---------|-------------| +| `/dev/video2` | `--source /dev/video2` | **RGB camera via OpenCV (recommended)** | +| `realsense` | `--source realsense` | RealSense D435I via pyrealsense2 SDK | +| `realsense:SERIAL` | `--source realsense:243622073459` | Specific RealSense by serial | +| `/dev/video4` | `--source /dev/video4` | Second RGB stream (if available) | +| `0` | `--source 0` | First OpenCV camera index | +| `video.mp4` | `--source video.mp4` | Video file | +| `image.jpg` | `--source image.jpg` | Single image | + +### G1 Robot V4L2 Device Map (RealSense D435I): +``` +/dev/video0 - Stereo module (infrared) - won't open with OpenCV +/dev/video1 - Stereo metadata +/dev/video2 - RGB camera (640x480) ← USE THIS +/dev/video3 - RGB metadata +/dev/video4 - RGB camera (secondary stream) +``` + +### Detect cameras on robot: +```bash +# Find working RGB cameras +python -c " +import cv2 +for i in range(10): + cap = cv2.VideoCapture(f'/dev/video{i}', cv2.CAP_V4L2) + if cap.isOpened(): + ret, frame = cap.read() + if ret and frame is not None: + print(f'/dev/video{i}: {frame.shape} OK') + else: + print(f'/dev/video{i}: opened but no frame') + cap.release() +" + +# RealSense devices +rs-enumerate-devices | grep "Serial Number" +``` + +--- + +## Tuning Parameters + +| Parameter | Default | Flag | Description | +|-----------|---------|------|-------------| +| Confidence | 0.35 | `--conf 0.35` | Lower = more detections, higher = fewer false positives | +| Max Missing | 90 | `--max-missing 90` | Frames before track deleted (~3s at 30fps) | +| Match Distance | 250 | `--match-distance 250` | Pixels for track matching | +| Confirm Frames | 5 | `--status-confirm-frames 5` | Frames to confirm a status change | + +### Recommended for G1 patrol: +```bash +python saqr.py --source realsense --model models/saqr_best.pt --headless \ + --conf 0.30 --max-missing 120 --match-distance 300 --status-confirm-frames 7 +``` + +--- + +## Compliance Rules + +| Status | Condition | Color | +|--------|-----------|-------| +| SAFE | Helmet AND vest detected, no violations | Green | +| PARTIAL | Only helmet OR only vest detected | Yellow | +| UNSAFE | `no-helmet` or `no-vest` detected, or nothing detected | Red | + +--- + +## Output Files + +| File | Location | Description | +|------|----------|-------------| +| `result.csv` | `captures/result.csv` | Current state of all tracked persons | +| `events.csv` | `captures/events.csv` | Audit log (NEW / STATUS_CHANGE events) | +| Person crops | `captures/SAFE/*.jpg` | Cropped images of compliant workers | +| Person crops | `captures/PARTIAL/*.jpg` | Workers with incomplete PPE | +| Person crops | `captures/UNSAFE/*.jpg` | Workers violating PPE rules | +| Logs | `Logs/Inference/saqr.log` | Runtime log | + +--- + +## Project Files + +| File | Purpose | +|------|---------| +| `saqr.py` | Main PPE tracking + detection (RealSense + OpenCV) | +| `saqr_g1_bridge.py` | Saqr → G1 bridge (onboard TTS + `reject` arm action on UNSAFE/SAFE transitions) | +| `detect.py` | Simple detection without tracking | +| `gui.py` | PySide6 desktop GUI | +| `manager.py` | Photo management CLI + CSV export | +| `train.py` | YOLO model training | +| `logger.py` | Centralized logging | +| `deploy.sh` | One-command deploy to robot | +| `Config/logging.json` | Log settings | + +--- + +## Troubleshooting + +### RealSense not detected +```bash +# Check USB connection +lsusb | grep Intel + +# Re-enumerate +rs-enumerate-devices | head -10 + +# Reset USB (if needed) +sudo usbreset /dev/bus/usb/002/002 +``` + +### Camera not opening +```bash +# Test RealSense directly +python -c " +import pyrealsense2 as rs +pipe = rs.pipeline() +cfg = rs.config() +cfg.enable_stream(rs.stream.color, 640, 480, rs.format.bgr8, 30) +pipe.start(cfg) +frames = pipe.wait_for_frames() +print('Frame:', frames.get_color_frame().get_width(), 'x', frames.get_color_frame().get_height()) +pipe.stop() +" + +# Test OpenCV fallback +python -c "import cv2; c=cv2.VideoCapture(0); print('OK' if c.isOpened() else 'FAIL'); c.release()" + +# Try different source +python saqr.py --source /dev/video0 --model models/saqr_best.pt --headless +``` + +### ModuleNotFoundError: ultralytics +```bash +# Check you're in the right conda env +which python +# Should show: /home/unitree/miniconda3/envs/teleimager/bin/python + +# Install to the correct env +python -m pip install ultralytics +``` + +### System clock wrong (SSL errors) +```bash +sudo date -s "2026-04-10 15:00:00" +``` + +### Model not found +```bash +ls ~/Saqr/models/ +# Should show: saqr_best.pt (~5.3 MB) +``` + +### Low FPS on Jetson +```bash +# Use smaller confidence to reduce load +python saqr.py --source realsense --conf 0.5 --headless + +# Or use headless opencv +export DISPLAY= +python saqr.py --source realsense --headless +``` + +### Too many duplicate track IDs +```bash +# Increase tolerance +python saqr.py --source realsense --max-missing 150 --match-distance 300 --headless +``` diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..6da8361 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,129 @@ +#!/bin/bash +# ============================================================================ +# Saqr PPE Detection - Deploy to Unitree G1 +# ============================================================================ +# +# Usage (from your dev machine): +# ./deploy.sh # deploy + install deps +# ./deploy.sh --run # deploy + install + start detection +# ./deploy.sh --run --source 0 # deploy + start with camera 0 +# +# ============================================================================ + +set -e + +# ── Robot config ────────────────────────────────────────────────────────────── +ROBOT_IP="${ROBOT_IP:-192.168.123.164}" +ROBOT_USER="${ROBOT_USER:-unitree}" +ROBOT_ENV="${ROBOT_ENV:-teleimager}" +REMOTE_DIR="/home/${ROBOT_USER}/Saqr" +SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=10" + +# ── Parse args ──────────────────────────────────────────────────────────────── +RUN_AFTER=false +SOURCE="0" +MODEL="models/saqr_best.pt" +HEADLESS=false + +while [[ $# -gt 0 ]]; do + case $1 in + --run) RUN_AFTER=true; shift ;; + --source) SOURCE="$2"; shift 2 ;; + --model) MODEL="$2"; shift 2 ;; + --headless) HEADLESS=true; shift ;; + --ip) ROBOT_IP="$2"; shift 2 ;; + *) echo "Unknown arg: $1"; exit 1 ;; + esac +done + +LOCAL_DIR="$(cd "$(dirname "$0")" && pwd)" + +echo "============================================" +echo " Saqr PPE - Deploy to Unitree G1" +echo "============================================" +echo " Robot : ${ROBOT_USER}@${ROBOT_IP}" +echo " Env : ${ROBOT_ENV}" +echo " Remote: ${REMOTE_DIR}" +echo "============================================" + +# ── Step 1: Test connection ─────────────────────────────────────────────────── +echo "" +echo "[1/5] Testing SSH connection..." +ssh ${SSH_OPTS} ${ROBOT_USER}@${ROBOT_IP} "echo 'Connected OK'" || { + echo "[ERROR] Cannot reach ${ROBOT_IP}. Is the robot on?" + exit 1 +} + +# ── Step 2: Create remote directory ─────────────────────────────────────────── +echo "[2/5] Creating remote directory..." +ssh ${SSH_OPTS} ${ROBOT_USER}@${ROBOT_IP} "mkdir -p ${REMOTE_DIR}/{models,captures/{SAFE,PARTIAL,UNSAFE},Config,Logs}" + +# ── Step 3: Copy project files ──────────────────────────────────────────────── +echo "[3/5] Copying project files..." + +# Python files +for f in saqr.py train.py detect.py manager.py logger.py gui.py requirements.txt; do + if [ -f "${LOCAL_DIR}/${f}" ]; then + scp ${SSH_OPTS} "${LOCAL_DIR}/${f}" ${ROBOT_USER}@${ROBOT_IP}:${REMOTE_DIR}/ + fi +done + +# Config +scp ${SSH_OPTS} "${LOCAL_DIR}/Config/logging.json" ${ROBOT_USER}@${ROBOT_IP}:${REMOTE_DIR}/Config/ + +# Trained model (this is the big file) +if [ -f "${LOCAL_DIR}/models/saqr_best.pt" ]; then + echo " Uploading trained model (saqr_best.pt)..." + scp ${SSH_OPTS} "${LOCAL_DIR}/models/saqr_best.pt" ${ROBOT_USER}@${ROBOT_IP}:${REMOTE_DIR}/models/ +else + echo " [WARN] models/saqr_best.pt not found - train first!" +fi + +# Base model (for retraining on robot if needed) +if [ -f "${LOCAL_DIR}/models/yolo11n.pt" ]; then + scp ${SSH_OPTS} "${LOCAL_DIR}/models/yolo11n.pt" ${ROBOT_USER}@${ROBOT_IP}:${REMOTE_DIR}/models/ +fi + +echo " Files copied." + +# ── Step 4: Install dependencies ────────────────────────────────────────────── +echo "[4/5] Installing dependencies on robot..." +ssh ${SSH_OPTS} ${ROBOT_USER}@${ROBOT_IP} << 'INSTALL_EOF' + source ~/miniconda3/etc/profile.d/conda.sh + conda activate teleimager + + pip install -q ultralytics opencv-python numpy PyYAML 2>/dev/null + echo " Dependencies OK" +INSTALL_EOF + +# ── Step 5: Optionally run ──────────────────────────────────────────────────── +if [ "$RUN_AFTER" = true ]; then + echo "[5/5] Starting Saqr PPE detection on robot..." + + HEADLESS_FLAG="" + if [ "$HEADLESS" = true ]; then + HEADLESS_FLAG="--headless" + fi + + ssh ${SSH_OPTS} -t ${ROBOT_USER}@${ROBOT_IP} << RUN_EOF + source ~/miniconda3/etc/profile.d/conda.sh + conda activate teleimager + cd ${REMOTE_DIR} + python saqr.py --source ${SOURCE} --model ${MODEL} ${HEADLESS_FLAG} +RUN_EOF +else + echo "[5/5] Skipped (use --run to start after deploy)" + echo "" + echo "============================================" + echo " Deployed! SSH in to run:" + echo "============================================" + echo "" + echo " ssh ${ROBOT_USER}@${ROBOT_IP}" + echo " conda activate teleimager" + echo " cd ${REMOTE_DIR}" + echo " python saqr.py --source 0 --model models/saqr_best.pt" + echo "" + echo " Or with GUI:" + echo " python gui.py --source 0 --model models/saqr_best.pt" + echo "" +fi diff --git a/detect.py b/detect.py new file mode 100644 index 0000000..7ef3cda --- /dev/null +++ b/detect.py @@ -0,0 +1,147 @@ +""" +Saqr - PPE Detection | Simple Detection (no tracking) +======================================================== +Single-pass YOLO inference: draw PPE boxes on frame, no person tracking. +Green = PPE worn, Red = PPE missing. + +Usage: + python detect.py --source 0 + python detect.py --source image.jpg --model models/saqr_best.pt +""" + +import argparse +import time +from pathlib import Path + +import cv2 +from ultralytics import YOLO + +from logger import get_logger + +log = get_logger("Inference", "detect") + +# Global inference config (set by main()) +_INFER_KWARGS: dict = {"device": "cpu", "half": False, "imgsz": 640} + +VIOLATION = {"no-helmet", "no-vest", "no-boots", "no-gloves", "no-goggles"} +COMPLIANT = {"helmet", "vest", "boots", "gloves", "goggles"} +GREEN = (0, 200, 0) +RED = (0, 0, 220) +BLUE = (200, 100, 0) +WHITE = (255, 255, 255) + + +def box_color(label: str): + if label in VIOLATION: + return RED + if label in COMPLIANT: + return GREEN + return BLUE + + +def draw_boxes(frame, results, model): + for box in results.boxes: + cls_id = int(box.cls) + label = model.names[cls_id] + conf = float(box.conf) + x1, y1, x2, y2 = map(int, box.xyxy[0]) + color = box_color(label) + + cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2) + text = f"{label} {conf:.2f}" + (tw, th), _ = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, 0.48, 1) + y_t = max(y1, th + 6) + cv2.rectangle(frame, (x1, y_t - th - 4), (x1 + tw + 4, y_t), color, -1) + cv2.putText(frame, text, (x1 + 2, y_t - 3), + cv2.FONT_HERSHEY_SIMPLEX, 0.48, WHITE, 1, cv2.LINE_AA) + + +def run_video(model, source, conf): + cap = cv2.VideoCapture(int(source) if source.isdigit() else source) + if not cap.isOpened(): + log.error(f"Cannot open: {source}") + return + + print("Running - q to quit, s to save.") + prev = time.time() + while True: + ret, frame = cap.read() + if not ret: + break + results = model(frame, conf=conf, verbose=False, **_INFER_KWARGS)[0] + draw_boxes(frame, results, model) + + fps = 1.0 / max(time.time() - prev, 1e-9) + prev = time.time() + cv2.putText(frame, f"FPS: {fps:.1f}", (10, 30), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, WHITE, 2, cv2.LINE_AA) + + cv2.imshow("Saqr Detect", frame) + key = cv2.waitKey(1) & 0xFF + if key == ord("q"): + break + if key == ord("s"): + cv2.imwrite("detect_saved.jpg", frame) + print("Saved: detect_saved.jpg") + + cap.release() + cv2.destroyAllWindows() + + +def run_image(model, path, conf): + frame = cv2.imread(path) + if frame is None: + log.error(f"Cannot read: {path}") + return + results = model(frame, conf=conf, verbose=False)[0] + draw_boxes(frame, results, model) + out = Path(path).stem + "_detect.jpg" + cv2.imwrite(out, frame) + print(f"Saved: {out}") + cv2.imshow("Saqr Detect", frame) + cv2.waitKey(0) + cv2.destroyAllWindows() + + +def main(): + parser = argparse.ArgumentParser(description="Saqr simple PPE detection") + parser.add_argument("--source", default="0") + parser.add_argument("--model", default="models/saqr_best.pt") + parser.add_argument("--conf", type=float, default=0.35) + parser.add_argument("--device", default="0", help="'cpu', '0', 'cuda:0'") + parser.add_argument("--half", action="store_true", help="FP16 inference") + parser.add_argument("--imgsz", type=int, default=320, help="Inference size") + args = parser.parse_args() + + global _INFER_KWARGS + _INFER_KWARGS = {"device": args.device, "half": args.half, "imgsz": args.imgsz} + try: + import torch + if not torch.cuda.is_available() and args.device != "cpu": + log.warning("CUDA unavailable - falling back to CPU") + _INFER_KWARGS["device"] = "cpu" + _INFER_KWARGS["half"] = False + except ImportError: + pass + + root = Path(__file__).parent + model_path = root / args.model + if not model_path.exists(): + model_path = Path(args.model) + if not model_path.exists(): + log.error(f"Model not found: {args.model}") + raise SystemExit(1) + + model = YOLO(str(model_path)) + src = args.source + if src.isdigit() or Path(src).suffix.lower() in {".mp4", ".avi", ".mov", ".mkv"}: + run_video(model, src, args.conf) + elif Path(src).exists(): + run_image(model, src, args.conf) + else: + log.error(f"Source not found: {src}") + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/gui.py b/gui.py new file mode 100644 index 0000000..ad56a5b --- /dev/null +++ b/gui.py @@ -0,0 +1,531 @@ +""" +Saqr - PPE Safety Tracking | GUI Application +================================================= +PySide6 desktop GUI for real-time PPE compliance monitoring. + +Features: + - Live camera feed with PPE detection overlays + - Start / Stop / Source selection + - Real-time SAFE / PARTIAL / UNSAFE counters + - Track list with per-person status + - Event log panel + - Confidence & tracking parameter controls + - Capture gallery sidebar + - G1 robot camera support (RealSense / V4L2 /dev/videoX) + +Usage: + python gui.py + python gui.py --model models/saqr_best.pt + python gui.py --source 1 +""" + +from __future__ import annotations + +import sys +import time +from pathlib import Path +from datetime import datetime +from typing import Dict, List, Optional + +import cv2 +import numpy as np +from PySide6.QtCore import Qt, QThread, Signal, Slot, QTimer, QSize +from PySide6.QtGui import QImage, QPixmap, QFont, QColor, QIcon +from PySide6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QLabel, QPushButton, + QVBoxLayout, QHBoxLayout, QGridLayout, QGroupBox, QComboBox, + QSlider, QSpinBox, QDoubleSpinBox, QTextEdit, QSplitter, + QFrame, QScrollArea, QFileDialog, QMessageBox, QStatusBar, +) + +from ultralytics import YOLO + +# Import Saqr core modules +from saqr import ( + collect_detections, group_detections_to_people, status_from_items, + split_wearing_missing, PersonTracker, EventLogger, Track, + save_track_image, emit_event, write_result_csv, draw_track, + draw_counters, setup_capture_dirs, resolve_model_path, + clamp_bbox, STATUSES, EVENTS_CSV, RESULT_CSV, ROOT, CAPTURES_DIR, + now_iso, +) + +from logger import get_logger + +log = get_logger("Inference", "gui") + + +# ── Camera backends (from AI_Photographer patterns) ────────────────────────── +def list_cameras(max_idx: int = 10) -> List[str]: + """Scan for available camera devices.""" + sources = [] + # V4L2 devices + for i in range(max_idx): + dev = f"/dev/video{i}" + if Path(dev).exists(): + sources.append(dev) + # Fallback numeric indices + if not sources: + for i in range(4): + cap = cv2.VideoCapture(i) + if cap.isOpened(): + sources.append(str(i)) + cap.release() + return sources if sources else ["0"] + + +def open_camera(source: str, width: int = 640, height: int = 480, fps: int = 30): + """Open camera with V4L2 backend and MJPEG codec (G1 compatible).""" + if source.startswith("/dev/video"): + cap = cv2.VideoCapture(source, cv2.CAP_V4L2) + elif source.isdigit(): + cap = cv2.VideoCapture(int(source)) + else: + cap = cv2.VideoCapture(source) + + if cap.isOpened(): + cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) + cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"MJPG")) + cap.set(cv2.CAP_PROP_FRAME_WIDTH, width) + cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height) + cap.set(cv2.CAP_PROP_FPS, fps) + return cap + + +# ── Detection Worker Thread ─────────────────────────────────────────────────── +class DetectionWorker(QThread): + frame_ready = Signal(np.ndarray, list) # annotated frame, visible tracks + event_fired = Signal(str) # event message string + stats_updated = Signal(dict) # {SAFE: n, PARTIAL: n, UNSAFE: n, fps: f} + + def __init__(self, parent=None): + super().__init__(parent) + self._running = False + self.model: Optional[YOLO] = None + self.source = "0" + self.conf = 0.35 + self.max_missing = 90 + self.match_distance = 250.0 + self.status_confirm = 5 + self.capture_dirs: Dict[str, Path] = {} + + def configure(self, model_path: str, source: str, conf: float, + max_missing: int, match_dist: float, status_confirm: int): + self.source = source + self.conf = conf + self.max_missing = max_missing + self.match_distance = match_dist + self.status_confirm = status_confirm + self.capture_dirs = setup_capture_dirs(ROOT) + if self.model is None or str(model_path) != getattr(self, '_last_model', ''): + self.model = YOLO(model_path) + self._last_model = str(model_path) + + def run(self): + self._running = True + cap = open_camera(self.source) + if not cap.isOpened(): + self.event_fired.emit(f"[ERROR] Cannot open camera: {self.source}") + return + + ok, first = cap.read() + if not ok: + self.event_fired.emit("[ERROR] Cannot read first frame") + cap.release() + return + + event_logger = EventLogger(EVENTS_CSV) + tracker = PersonTracker( + event_logger=event_logger, + max_missing=self.max_missing, + match_distance=self.match_distance, + status_confirm_frames=self.status_confirm, + ) + + self.event_fired.emit(f"Session started | source={self.source}") + prev = time.time() + frame_idx = 0 + frame = first + + while self._running: + frame_idx += 1 + h, w = frame.shape[:2] + annotated = frame.copy() + + try: + detections = collect_detections(frame, self.model, self.conf) + candidates = group_detections_to_people(detections, w, h) + created, changed = tracker.update(candidates, frame_idx) + visible = tracker.visible_tracks() + + created_ids = {t.track_id for t in created} + changed_ids = {t.track_id for t in changed} + event_ids = created_ids | changed_ids + + for track in visible: + save_track_image(frame, track, self.capture_dirs) + if track.track_id in event_ids: + ev_type = "NEW" if track.track_id in created_ids else "STATUS_CHANGE" + wearing, missing, unknown = split_wearing_missing(track.items) + msg = ( + f"ID {track.track_id:04d} | {ev_type} | {track.status} | " + f"W: {', '.join(wearing) or 'none'} | " + f"M: {', '.join(missing) or 'none'}" + ) + self.event_fired.emit(msg) + emit_event(track, event_logger, ev_type) + draw_track(annotated, track) + + # Write CSV periodically + if frame_idx % 30 == 0: + write_result_csv(list(tracker.tracks.values()), RESULT_CSV) + + except Exception as e: + self.event_fired.emit(f"[ERROR] Frame {frame_idx}: {e}") + visible = tracker.visible_tracks() + + now_t = time.time() + fps = 1.0 / max(now_t - prev, 1e-9) + prev = now_t + + draw_counters(annotated, visible, fps) + + # Emit signals + counts = {s: 0 for s in STATUSES} + for t in visible: + counts[t.status] += 1 + counts["fps"] = fps + counts["tracks"] = len(visible) + + self.frame_ready.emit(annotated, visible) + self.stats_updated.emit(counts) + + ret, frame = cap.read() + if not ret: + break + + cap.release() + write_result_csv(list(tracker.tracks.values()), RESULT_CSV) + self.event_fired.emit("Session ended.") + + def stop(self): + self._running = False + + +# ── Helpers ─────────────────────────────────────────────────────────────────── +def cv_to_qpixmap(frame: np.ndarray, max_w: int = 960, max_h: int = 720) -> QPixmap: + h, w, ch = frame.shape + rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + qimg = QImage(rgb.data, w, h, ch * w, QImage.Format.Format_RGB888) + pix = QPixmap.fromImage(qimg) + return pix.scaled(max_w, max_h, Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation) + + +# ── Main Window ─────────────────────────────────────────────────────────────── +class SaqrWindow(QMainWindow): + def __init__(self, default_model: str = "models/saqr_best.pt", + default_source: str = "0"): + super().__init__() + self.setWindowTitle("Saqr - PPE Safety Tracking") + self.setMinimumSize(1200, 700) + self._default_model = default_model + self._default_source = default_source + + self.worker: Optional[DetectionWorker] = None + self._build_ui() + self._scan_cameras() + + def _build_ui(self): + central = QWidget() + self.setCentralWidget(central) + main_layout = QHBoxLayout(central) + + # ── Left: Controls ──────────────────────────────────────────────── + left = QVBoxLayout() + left.setMaximumWidth = 300 + + # Model + model_grp = QGroupBox("Model") + model_lay = QVBoxLayout(model_grp) + self.model_label = QLabel(self._default_model) + self.model_label.setWordWrap(True) + btn_model = QPushButton("Browse...") + btn_model.clicked.connect(self._browse_model) + model_lay.addWidget(self.model_label) + model_lay.addWidget(btn_model) + left.addWidget(model_grp) + + # Camera + cam_grp = QGroupBox("Camera Source") + cam_lay = QVBoxLayout(cam_grp) + self.cam_combo = QComboBox() + btn_refresh = QPushButton("Refresh") + btn_refresh.clicked.connect(self._scan_cameras) + cam_lay.addWidget(self.cam_combo) + cam_lay.addWidget(btn_refresh) + left.addWidget(cam_grp) + + # Parameters + param_grp = QGroupBox("Parameters") + param_lay = QGridLayout(param_grp) + + param_lay.addWidget(QLabel("Confidence:"), 0, 0) + self.conf_spin = QDoubleSpinBox() + self.conf_spin.setRange(0.1, 0.9) + self.conf_spin.setSingleStep(0.05) + self.conf_spin.setValue(0.35) + param_lay.addWidget(self.conf_spin, 0, 1) + + param_lay.addWidget(QLabel("Max Missing:"), 1, 0) + self.missing_spin = QSpinBox() + self.missing_spin.setRange(10, 300) + self.missing_spin.setValue(90) + param_lay.addWidget(self.missing_spin, 1, 1) + + param_lay.addWidget(QLabel("Match Dist:"), 2, 0) + self.dist_spin = QDoubleSpinBox() + self.dist_spin.setRange(50, 500) + self.dist_spin.setSingleStep(10) + self.dist_spin.setValue(250) + param_lay.addWidget(self.dist_spin, 2, 1) + + param_lay.addWidget(QLabel("Confirm Frames:"), 3, 0) + self.confirm_spin = QSpinBox() + self.confirm_spin.setRange(1, 20) + self.confirm_spin.setValue(5) + param_lay.addWidget(self.confirm_spin, 3, 1) + + left.addWidget(param_grp) + + # Start / Stop + btn_lay = QHBoxLayout() + self.btn_start = QPushButton("Start") + self.btn_start.setStyleSheet("background-color: #2ecc71; color: white; font-weight: bold; padding: 8px;") + self.btn_start.clicked.connect(self._start) + self.btn_stop = QPushButton("Stop") + self.btn_stop.setStyleSheet("background-color: #e74c3c; color: white; font-weight: bold; padding: 8px;") + self.btn_stop.clicked.connect(self._stop) + self.btn_stop.setEnabled(False) + btn_lay.addWidget(self.btn_start) + btn_lay.addWidget(self.btn_stop) + left.addLayout(btn_lay) + + # Status counters + stats_grp = QGroupBox("Live Status") + stats_lay = QGridLayout(stats_grp) + self.lbl_fps = QLabel("FPS: -") + self.lbl_safe = QLabel("SAFE: 0") + self.lbl_partial = QLabel("PARTIAL: 0") + self.lbl_unsafe = QLabel("UNSAFE: 0") + self.lbl_tracks = QLabel("TRACKS: 0") + + self.lbl_safe.setStyleSheet("color: #27ae60; font-weight: bold; font-size: 14px;") + self.lbl_partial.setStyleSheet("color: #f39c12; font-weight: bold; font-size: 14px;") + self.lbl_unsafe.setStyleSheet("color: #e74c3c; font-weight: bold; font-size: 14px;") + self.lbl_tracks.setStyleSheet("color: #3498db; font-weight: bold; font-size: 14px;") + + stats_lay.addWidget(self.lbl_fps, 0, 0) + stats_lay.addWidget(self.lbl_tracks, 0, 1) + stats_lay.addWidget(self.lbl_safe, 1, 0) + stats_lay.addWidget(self.lbl_partial, 1, 1) + stats_lay.addWidget(self.lbl_unsafe, 2, 0, 1, 2) + left.addWidget(stats_grp) + + left.addStretch() + + # ── Centre: Video feed ──────────────────────────────────────────── + centre = QVBoxLayout() + self.video_label = QLabel("No camera feed") + self.video_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.video_label.setStyleSheet( + "background-color: #1a1a2e; color: #666; font-size: 18px; border-radius: 8px;" + ) + self.video_label.setMinimumSize(640, 480) + centre.addWidget(self.video_label) + + # ── Right: Event log ────────────────────────────────────────────── + right = QVBoxLayout() + log_grp = QGroupBox("Event Log") + log_lay = QVBoxLayout(log_grp) + self.event_log = QTextEdit() + self.event_log.setReadOnly(True) + self.event_log.setMaximumWidth(380) + self.event_log.setStyleSheet( + "background-color: #0d1117; color: #c9d1d9; font-family: monospace; font-size: 11px;" + ) + log_lay.addWidget(self.event_log) + + btn_clear = QPushButton("Clear Log") + btn_clear.clicked.connect(self.event_log.clear) + log_lay.addWidget(btn_clear) + + btn_export = QPushButton("Export CSV Report") + btn_export.clicked.connect(self._export_csv) + log_lay.addWidget(btn_export) + + right.addWidget(log_grp) + + # ── Assemble ────────────────────────────────────────────────────── + left_widget = QWidget() + left_widget.setLayout(left) + left_widget.setFixedWidth(260) + + centre_widget = QWidget() + centre_widget.setLayout(centre) + + right_widget = QWidget() + right_widget.setLayout(right) + right_widget.setFixedWidth(380) + + main_layout.addWidget(left_widget) + main_layout.addWidget(centre_widget, stretch=1) + main_layout.addWidget(right_widget) + + # Status bar + self.statusBar().showMessage("Ready - load a model and start detection") + + def _scan_cameras(self): + self.cam_combo.clear() + sources = list_cameras() + self.cam_combo.addItems(sources) + # Set default + idx = self.cam_combo.findText(self._default_source) + if idx >= 0: + self.cam_combo.setCurrentIndex(idx) + elif self.cam_combo.count() > 0: + # Try to add the default as custom + self.cam_combo.addItem(self._default_source) + self.cam_combo.setCurrentIndex(self.cam_combo.count() - 1) + + def _browse_model(self): + path, _ = QFileDialog.getOpenFileName( + self, "Select YOLO Model", str(ROOT / "models"), "Model Files (*.pt)" + ) + if path: + self.model_label.setText(path) + + def _start(self): + model_path = self.model_label.text() + if not Path(model_path).exists(): + # Try relative to ROOT + full = ROOT / model_path + if not full.exists(): + QMessageBox.critical(self, "Error", f"Model not found:\n{model_path}") + return + model_path = str(full) + + source = self.cam_combo.currentText() + + self.worker = DetectionWorker() + self.worker.configure( + model_path=model_path, + source=source, + conf=self.conf_spin.value(), + max_missing=self.missing_spin.value(), + match_dist=self.dist_spin.value(), + status_confirm=self.confirm_spin.value(), + ) + self.worker.frame_ready.connect(self._on_frame) + self.worker.event_fired.connect(self._on_event) + self.worker.stats_updated.connect(self._on_stats) + self.worker.finished.connect(self._on_finished) + self.worker.start() + + self.btn_start.setEnabled(False) + self.btn_stop.setEnabled(True) + self.statusBar().showMessage(f"Running | source={source} | conf={self.conf_spin.value()}") + log.info(f"GUI session started | source={source}") + + def _stop(self): + if self.worker and self.worker.isRunning(): + self.worker.stop() + self.worker.wait(3000) + self.btn_start.setEnabled(True) + self.btn_stop.setEnabled(False) + self.statusBar().showMessage("Stopped") + + @Slot(np.ndarray, list) + def _on_frame(self, frame: np.ndarray, visible: list): + pix = cv_to_qpixmap(frame, self.video_label.width(), self.video_label.height()) + self.video_label.setPixmap(pix) + + @Slot(str) + def _on_event(self, msg: str): + ts = datetime.now().strftime("%H:%M:%S") + color = "#c9d1d9" + if "UNSAFE" in msg: + color = "#f85149" + elif "SAFE" in msg and "UNSAFE" not in msg: + color = "#3fb950" + elif "PARTIAL" in msg: + color = "#d29922" + elif "ERROR" in msg: + color = "#f85149" + + self.event_log.append(f'[{ts}] {msg}') + # Auto-scroll + self.event_log.verticalScrollBar().setValue( + self.event_log.verticalScrollBar().maximum() + ) + + @Slot(dict) + def _on_stats(self, stats: dict): + self.lbl_fps.setText(f"FPS: {stats.get('fps', 0):.1f}") + self.lbl_safe.setText(f"SAFE: {stats.get('SAFE', 0)}") + self.lbl_partial.setText(f"PARTIAL: {stats.get('PARTIAL', 0)}") + self.lbl_unsafe.setText(f"UNSAFE: {stats.get('UNSAFE', 0)}") + self.lbl_tracks.setText(f"TRACKS: {stats.get('tracks', 0)}") + + def _on_finished(self): + self.btn_start.setEnabled(True) + self.btn_stop.setEnabled(False) + self.statusBar().showMessage("Session ended") + + def _export_csv(self): + path, _ = QFileDialog.getSaveFileName( + self, "Export CSV", str(ROOT / f"ppe_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"), + "CSV Files (*.csv)" + ) + if path: + from manager import export_csv, load_photos + export_csv(load_photos(), Path(path)) + self._on_event(f"Exported: {path}") + + def closeEvent(self, event): + self._stop() + event.accept() + + +# ── Entry point ─────────────────────────────────────────────────────────────── +def main(): + import argparse + parser = argparse.ArgumentParser(description="Saqr PPE GUI") + parser.add_argument("--model", default="models/saqr_best.pt") + parser.add_argument("--source", default="0") + args = parser.parse_args() + + app = QApplication(sys.argv) + app.setStyle("Fusion") + + # Dark theme + from PySide6.QtGui import QPalette + palette = QPalette() + palette.setColor(QPalette.ColorRole.Window, QColor(30, 30, 46)) + palette.setColor(QPalette.ColorRole.WindowText, QColor(205, 214, 244)) + palette.setColor(QPalette.ColorRole.Base, QColor(24, 24, 37)) + palette.setColor(QPalette.ColorRole.AlternateBase, QColor(30, 30, 46)) + palette.setColor(QPalette.ColorRole.Text, QColor(205, 214, 244)) + palette.setColor(QPalette.ColorRole.Button, QColor(49, 50, 68)) + palette.setColor(QPalette.ColorRole.ButtonText, QColor(205, 214, 244)) + palette.setColor(QPalette.ColorRole.Highlight, QColor(137, 180, 250)) + palette.setColor(QPalette.ColorRole.HighlightedText, QColor(30, 30, 46)) + app.setPalette(palette) + + win = SaqrWindow(default_model=args.model, default_source=args.source) + win.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/logger.py b/logger.py new file mode 100644 index 0000000..63a8a7d --- /dev/null +++ b/logger.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Dict + +_LOGGER_CACHE: Dict[str, logging.Logger] = {} + +_ROOT = Path(__file__).resolve().parent + + +def _load_log_cfg() -> dict: + cfg_path = _ROOT / "Config" / "logging.json" + try: + with open(cfg_path, "r") as f: + return json.load(f) + except Exception: + return {} + + +def _level_from_name(name: str) -> int: + return getattr(logging, str(name).upper(), logging.INFO) + + +def get_logger(category: str, name: str) -> logging.Logger: + """Return a cached logger that writes to Logs//.log.""" + key = f"{category}.{name}" + if key in _LOGGER_CACHE: + return _LOGGER_CACHE[key] + + log_cfg = _load_log_cfg() + + log_dir = _ROOT / "Logs" / category + log_dir.mkdir(parents=True, exist_ok=True) + + logger = logging.getLogger(key) + level_name = ( + log_cfg.get("categories", {}).get(category) + or log_cfg.get("level", "INFO") + ) + logger.setLevel(_level_from_name(level_name)) + logger.propagate = False + + if logger.handlers: + logger.handlers.clear() + + fmt = logging.Formatter( + log_cfg.get("format", "%(asctime)s | %(name)s | %(levelname)s | %(message)s") + ) + + if log_cfg.get("file", True): + fh = logging.FileHandler(log_dir / f"{name}.log", encoding="utf-8") + fh.setFormatter(fmt) + logger.addHandler(fh) + + if log_cfg.get("console", False): + sh = logging.StreamHandler() + sh.setFormatter(fmt) + logger.addHandler(sh) + + _LOGGER_CACHE[key] = logger + return logger diff --git a/manager.py b/manager.py new file mode 100644 index 0000000..6f6aabf --- /dev/null +++ b/manager.py @@ -0,0 +1,437 @@ +""" +Saqr - PPE Detection | Photo Manager +======================================== +Interactive CLI to manage captured PPE photos. + +Features: list, view, move, rename, assign ID, delete, + download/copy, export CSV, update status. + +Usage: + python manager.py # interactive menu + python manager.py --export # quick CSV export +""" + +from __future__ import annotations + +import argparse +import csv +import shutil +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +import cv2 + +from logger import get_logger + +log = get_logger("Manager", "manager") + +ROOT = Path(__file__).parent +CAPTURES_DIR = ROOT / "captures" +STATUSES = ("SAFE", "PARTIAL", "UNSAFE") +IMG_EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".webp"} + +CLASS_COLUMNS = [ + "boots", "gloves", "goggles", "helmet", + "no-boots", "no-gloves", "no-goggles", "no-helmet", "no-vest", "vest", +] + +# ANSI colours +_C = {"SAFE": "\033[92m", "PARTIAL": "\033[93m", "UNSAFE": "\033[91m", + "BOLD": "\033[1m", "RESET": "\033[0m"} + +def _cs(s): return f"{_C.get(s, '')}{s}{_C['RESET']}" +def _bold(s): return f"{_C['BOLD']}{s}{_C['RESET']}" + + +# ── Data models ─────────────────────────────────────────────────────────────── +@dataclass +class Photo: + path: Path + status: str + filename: str + person_id: str = "" + class_name: str = "unknown" + date_captured: str = "" + + @property + def class_flags(self) -> dict[str, int]: + flags = {c: 0 for c in CLASS_COLUMNS} + stem = self.filename.lower() + for c in CLASS_COLUMNS: + if c in stem: + flags[c] = 1 + return flags + + +@dataclass +class EventRow: + """One row from captures/events.csv (written by saqr.py).""" + timestamp: str + track_id: str + event_type: str + status: str + wearing: str + missing: str + unknown: str + photo: str + path: str + + @property + def class_flags(self) -> dict[str, int]: + worn = {c.strip() for c in self.wearing.split(",") if c.strip()} + return {c: (1 if c in worn else 0) for c in CLASS_COLUMNS} + + @property + def missing_notes(self) -> str: + items = [c.strip() for c in self.missing.split(",") if c.strip()] + return "Missing " + ", ".join(items) if items else "Compliant" + + +# ── Parsing & Loading ───────────────────────────────────────────────────────── +def parse_photo(path: Path, status: str) -> Photo: + stem = path.stem + parts = stem.split("_") + person_id = "" + date_captured = "" + class_name = "unknown" + + # Try to extract track_NNNN format + if stem.startswith("track_") and len(parts) >= 2 and parts[1].isdigit(): + person_id = f"track_{parts[1]}" + elif len(parts) >= 2 and parts[0].isdigit() and parts[1].isdigit(): + try: + date_captured = datetime.strptime( + f"{parts[0]}_{parts[1]}", "%Y%m%d_%H%M%S" + ).strftime("%Y-%m-%d %H:%M:%S") + except ValueError: + pass + if len(parts) > 3: + class_name = "_".join(parts[3:]) + + return Photo( + path=path, status=status, filename=path.name, + person_id=person_id, class_name=class_name, date_captured=date_captured, + ) + + +def load_photos() -> list[Photo]: + photos = [] + for status in STATUSES: + folder = CAPTURES_DIR / status + if not folder.exists(): + continue + for f in sorted(folder.iterdir()): + if f.suffix.lower() in IMG_EXTS: + photos.append(parse_photo(f, status)) + return photos + + +def load_events_csv(path: Path) -> list[EventRow]: + if not path.exists(): + return [] + rows = [] + with open(path, "r", newline="", encoding="utf-8") as f: + for row in csv.DictReader(f): + rows.append(EventRow( + timestamp=row.get("timestamp", ""), + track_id=row.get("track_id", ""), + event_type=row.get("event_type", ""), + status=row.get("status", ""), + wearing=row.get("wearing", ""), + missing=row.get("missing", ""), + unknown=row.get("unknown", ""), + photo=row.get("photo", ""), + path=row.get("path", ""), + )) + return rows + + +# ── Core operations ─────────────────────────────────────────────────────────── +def move_photo(photo: Photo, new_status: str) -> Photo: + dst_dir = CAPTURES_DIR / new_status + dst_dir.mkdir(parents=True, exist_ok=True) + dst = dst_dir / photo.filename + shutil.move(str(photo.path), str(dst)) + log.info(f"Moved '{photo.filename}': {photo.status} -> {new_status}") + return Photo(path=dst, status=new_status, filename=photo.filename, + person_id=photo.person_id, class_name=photo.class_name, + date_captured=photo.date_captured) + + +def rename_photo(photo: Photo, new_name: str) -> Photo: + if Path(new_name).suffix.lower() not in IMG_EXTS: + new_name += photo.path.suffix + dst = photo.path.parent / new_name + photo.path.rename(dst) + log.info(f"Renamed: '{photo.filename}' -> '{new_name}'") + return Photo(path=dst, status=photo.status, filename=new_name, + person_id=photo.person_id, class_name=photo.class_name, + date_captured=photo.date_captured) + + +def assign_id(photo: Photo, pid: str) -> Photo: + pid = pid.strip().replace(" ", "_") + dt = datetime.now().strftime("%Y%m%d_%H%M%S") + cls = photo.class_name if photo.class_name != "unknown" else "ppe" + return rename_photo(photo, f"{pid}_{dt}_{cls}{photo.path.suffix}") + + +def delete_photo(photo: Photo) -> None: + photo.path.unlink() + log.info(f"Deleted: '{photo.filename}' ({photo.status})") + + +def copy_photo(photo: Photo, dest: Path) -> Path: + dest.mkdir(parents=True, exist_ok=True) + dst = dest / photo.filename + shutil.copy2(str(photo.path), str(dst)) + log.info(f"Copied '{photo.filename}' -> {dst}") + return dst + + +def export_csv(photos: list[Photo], output: Path) -> None: + event_rows = load_events_csv(CAPTURES_DIR / "events.csv") + + fields = ["photo", "track_id", "event_type", "status", "timestamp", + "wearing", "missing", "unknown", "missing_notes", + *CLASS_COLUMNS, "path"] + + with open(output, "w", newline="", encoding="utf-8") as f: + w = csv.DictWriter(f, fieldnames=fields) + w.writeheader() + + if event_rows: + for r in event_rows: + w.writerow({ + "photo": r.photo, "track_id": r.track_id, + "event_type": r.event_type, "status": r.status, + "timestamp": r.timestamp, "wearing": r.wearing, + "missing": r.missing, "unknown": r.unknown, + "missing_notes": r.missing_notes, + **r.class_flags, "path": r.path, + }) + else: + for p in photos: + w.writerow({ + "photo": p.filename, "track_id": p.person_id, + "event_type": "", "status": p.status, + "timestamp": p.date_captured, "wearing": "", + "missing": "", "unknown": "", "missing_notes": "", + **p.class_flags, "path": str(p.path), + }) + + count = len(event_rows) if event_rows else len(photos) + log.info(f"CSV exported: {output} ({count} records)") + + +# ── Display ─────────────────────────────────────────────────────────────────── +def print_header(photos): + counts = {s: sum(1 for p in photos if p.status == s) for s in STATUSES} + print("\n" + "=" * 66) + print(_bold(" Saqr - PPE Photo Manager")) + print("=" * 66) + print(f" {_cs('SAFE')} {counts['SAFE']:3d} | " + f"{_cs('PARTIAL')} {counts['PARTIAL']:3d} | " + f"{_cs('UNSAFE')} {counts['UNSAFE']:3d} | Total: {len(photos)}") + print("=" * 66) + + +def print_table(photos): + print(f"\n {'#':>4} {'STATUS':<8} {'ID':<14} {'DATE':<19} FILENAME") + print(" " + "-" * 68) + for i, p in enumerate(photos): + pid = (p.person_id or "-")[:12] + date = (p.date_captured or "-")[:17] + print(f" {i+1:>4} {p.status:<8} {pid:<14} {date:<19} {p.filename[:28]}") + + +def pick_photo(photos, prompt="Select photo") -> Photo | None: + if not photos: + print(" No photos found.") + return None + print_table(photos) + try: + n = int(input(f"\n {prompt} (0=cancel): ")) + if 1 <= n <= len(photos): + return photos[n - 1] + except ValueError: + pass + return None + + +def show_details(photo): + print(f"\n Filename : {photo.filename}") + print(f" Status : {_cs(photo.status)}") + print(f" ID : {photo.person_id or '-'}") + print(f" Date : {photo.date_captured or '-'}") + print(f" Path : {photo.path}") + + +# ── Menu actions ────────────────────────────────────────────────────────────── +def act_list(photos): + print("\n Filter: [1] All [2] SAFE [3] PARTIAL [4] UNSAFE") + ch = input(" Choice: ").strip() + filt = {"2": "SAFE", "3": "PARTIAL", "4": "UNSAFE"}.get(ch) + sub = [p for p in photos if filt is None or p.status == filt] + if sub: + print_table(sub) + print(f"\n Showing {len(sub)} photo(s).") + else: + print(" None.") + + +def act_view(photos): + p = pick_photo(photos, "View photo") + if not p: + return + show_details(p) + img = cv2.imread(str(p.path)) + if img is not None: + cv2.imshow(f"Saqr - {p.filename}", img) + print(" Press any key to close.") + cv2.waitKey(0) + cv2.destroyAllWindows() + + +def act_move(photos): + p = pick_photo(photos, "Move photo") + if not p: + return photos + show_details(p) + print(f"\n Move to: [1] SAFE [2] PARTIAL [3] UNSAFE") + t = {"1": "SAFE", "2": "PARTIAL", "3": "UNSAFE"}.get(input(" Choice: ").strip()) + if not t or t == p.status: + return photos + move_photo(p, t) + print(f" Moved -> {_cs(t)}") + return load_photos() + + +def act_rename(photos): + p = pick_photo(photos, "Rename photo") + if not p: + return photos + name = input(" New filename: ").strip() + if name: + rename_photo(p, name) + return load_photos() + + +def act_assign_id(photos): + p = pick_photo(photos, "Assign ID to photo") + if not p: + return photos + pid = input(" Person ID (e.g. W001): ").strip() + if pid: + assign_id(p, pid) + return load_photos() + + +def act_delete(photos): + p = pick_photo(photos, "Delete photo") + if not p: + return photos + show_details(p) + if input(f" Delete '{p.filename}'? (yes/no): ").strip().lower() in ("y", "yes"): + delete_photo(p) + print(" Deleted.") + return load_photos() + + +def act_download(photos): + p = pick_photo(photos, "Download/copy photo") + if not p: + return + dest = input(" Destination folder: ").strip() + if dest: + dst = copy_photo(p, Path(dest).expanduser()) + print(f" Copied -> {dst}") + + +def act_export(photos): + default = ROOT / f"ppe_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" + out = input(f" Output [{default.name}]: ").strip() + output = Path(out).expanduser() if out else default + export_csv(photos, output) + print(f" Exported -> {output}") + + +def act_update(photos): + print("\n Re-classify: when PPE compliance changes (UNSAFE -> SAFE etc.)") + p = pick_photo(photos, "Update photo status") + if not p: + return photos + show_details(p) + print(f"\n New status: [1] SAFE [2] PARTIAL [3] UNSAFE") + t = {"1": "SAFE", "2": "PARTIAL", "3": "UNSAFE"}.get(input(" Choice: ").strip()) + if not t or t == p.status: + return photos + move_photo(p, t) + print(f" Updated -> {_cs(t)}") + return load_photos() + + +# ── Main menu ───────────────────────────────────────────────────────────────── +MENU = """ + [1] List photos + [2] View photo + [3] Move photo (change status) + [4] Rename photo + [5] Assign person ID + [6] Delete photo + [7] Download / Copy photo + [8] Export report to CSV + [9] Update status (re-classify) + [0] Exit +""" + + +def run(): + parser = argparse.ArgumentParser(description="Saqr Photo Manager") + parser.add_argument("--export", action="store_true", help="Quick CSV export") + args = parser.parse_args() + + if not CAPTURES_DIR.exists(): + print(f"[ERROR] captures/ not found. Run saqr.py first.") + raise SystemExit(1) + + photos = load_photos() + + if args.export: + out = ROOT / f"ppe_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" + export_csv(photos, out) + print(f"Exported: {out}") + return + + log.info("Manager started") + actions = { + "1": act_list, "2": act_view, "3": act_move, "4": act_rename, + "5": act_assign_id, "6": act_delete, "7": act_download, + "8": act_export, "9": act_update, + } + + while True: + photos = load_photos() + print_header(photos) + print(MENU) + ch = input(" Choice: ").strip() + + if ch == "0": + log.info("Manager ended") + print(" Bye.\n") + break + + action = actions.get(ch) + if action: + result = action(photos) + if isinstance(result, list): + photos = result + else: + print(" Unknown option.") + + input("\n Press Enter to continue...") + + +if __name__ == "__main__": + run() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ad1d250 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +ultralytics>=8.0.0 +opencv-python +numpy +PyYAML +PySide6>=6.5.0 diff --git a/run_local.sh b/run_local.sh new file mode 100755 index 0000000..feb2803 --- /dev/null +++ b/run_local.sh @@ -0,0 +1,110 @@ +#!/bin/bash +# ============================================================================ +# Saqr PPE Detection - Run on Local Laptop +# ============================================================================ +# +# Usage: +# ./run_local.sh # webcam 0 +# ./run_local.sh --source 1 # webcam 1 +# ./run_local.sh --source video.mp4 # video file +# ./run_local.sh --gui # PySide6 GUI +# ./run_local.sh --detect # simple detection (no tracking) +# +# ============================================================================ + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +# ── Defaults ────────────────────────────────────────────────────────────────── +SOURCE="0" +MODEL="models/saqr_best.pt" +CONF="0.35" +MODE="saqr" # saqr | gui | detect +HEADLESS=false +MAX_MISSING=90 +MATCH_DIST=250 +CONFIRM=5 + +# ── Parse args ──────────────────────────────────────────────────────────────── +while [[ $# -gt 0 ]]; do + case $1 in + --source) SOURCE="$2"; shift 2 ;; + --model) MODEL="$2"; shift 2 ;; + --conf) CONF="$2"; shift 2 ;; + --gui) MODE="gui"; shift ;; + --detect) MODE="detect"; shift ;; + --headless) HEADLESS=true; shift ;; + --max-missing) MAX_MISSING="$2"; shift 2 ;; + --match-distance) MATCH_DIST="$2"; shift 2 ;; + --confirm) CONFIRM="$2"; shift 2 ;; + *) echo "Unknown arg: $1"; exit 1 ;; + esac +done + +# ── Check model ─────────────────────────────────────────────────────────────── +if [ ! -f "$MODEL" ]; then + echo "[ERROR] Model not found: $MODEL" + echo "" + echo " Train first:" + echo " conda activate AI_MSI_yolo" + echo " python train.py --dataset dataset --epochs 100" + echo "" + echo " Or specify a different model:" + echo " ./run_local.sh --model /path/to/model.pt" + exit 1 +fi + +# ── Activate conda ──────────────────────────────────────────────────────────── +if command -v conda &>/dev/null; then + source "$(conda info --base)/etc/profile.d/conda.sh" 2>/dev/null || true + conda activate AI_MSI_yolo 2>/dev/null || true +fi + +echo "============================================" +echo " Saqr PPE Detection - Local Laptop" +echo "============================================" +echo " Mode : $MODE" +echo " Source : $SOURCE" +echo " Model : $MODEL" +echo " Conf : $CONF" +echo "============================================" +echo "" + +# ── Run ─────────────────────────────────────────────────────────────────────── +HEADLESS_FLAG="" +if [ "$HEADLESS" = true ]; then + HEADLESS_FLAG="--headless" +fi + +case $MODE in + saqr) + echo "Starting PPE tracking..." + echo " Press q to quit, s to save frame." + echo "" + python saqr.py \ + --source "$SOURCE" \ + --model "$MODEL" \ + --conf "$CONF" \ + --max-missing "$MAX_MISSING" \ + --match-distance "$MATCH_DIST" \ + --status-confirm-frames "$CONFIRM" \ + $HEADLESS_FLAG + ;; + gui) + echo "Starting GUI..." + python gui.py \ + --source "$SOURCE" \ + --model "$MODEL" + ;; + detect) + echo "Starting simple detection (no tracking)..." + echo " Press q to quit, s to save frame." + echo "" + python detect.py \ + --source "$SOURCE" \ + --model "$MODEL" \ + --conf "$CONF" + ;; +esac diff --git a/run_robot.sh b/run_robot.sh new file mode 100755 index 0000000..e4741aa --- /dev/null +++ b/run_robot.sh @@ -0,0 +1,116 @@ +#!/bin/bash +# ============================================================================ +# Saqr PPE Detection - Run on Unitree G1 Robot +# ============================================================================ +# +# Run on the robot's physical terminal (with monitor): +# ./run_robot.sh +# ./run_robot.sh --headless # no display +# ./run_robot.sh --source realsense # use pyrealsense2 SDK +# +# ============================================================================ + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +# ── Defaults ────────────────────────────────────────────────────────────────── +SOURCE="realsense" +MODEL="models/saqr_best.pt" +CONF="0.35" +HEADLESS=false +MAX_MISSING=120 +MATCH_DIST=300 +CONFIRM=7 +DEVICE="0" +IMGSZ=320 +HALF=true +STREAM_PORT=0 + +# ── Parse args ──────────────────────────────────────────────────────────────── +while [[ $# -gt 0 ]]; do + case $1 in + --source) SOURCE="$2"; shift 2 ;; + --model) MODEL="$2"; shift 2 ;; + --conf) CONF="$2"; shift 2 ;; + --headless) HEADLESS=true; shift ;; + --max-missing) MAX_MISSING="$2"; shift 2 ;; + --match-distance) MATCH_DIST="$2"; shift 2 ;; + --confirm) CONFIRM="$2"; shift 2 ;; + --device) DEVICE="$2"; shift 2 ;; + --imgsz) IMGSZ="$2"; shift 2 ;; + --no-half) HALF=false; shift ;; + --stream) STREAM_PORT="$2"; shift 2 ;; + --cpu) DEVICE="cpu"; HALF=false; shift ;; + *) echo "Unknown arg: $1"; exit 1 ;; + esac +done + +# ── Check model ─────────────────────────────────────────────────────────────── +if [ ! -f "$MODEL" ]; then + echo "[ERROR] Model not found: $MODEL" + echo " Deploy from dev machine: ./deploy.sh" + exit 1 +fi + +# ── Activate conda ──────────────────────────────────────────────────────────── +source ~/miniconda3/etc/profile.d/conda.sh 2>/dev/null || true +conda activate teleimager 2>/dev/null || true + +# ── Fix clock if needed ────────────────────────────────────────────────────── +YEAR=$(date +%Y) +if [ "$YEAR" -lt 2025 ]; then + echo "[WARN] System clock is wrong (year=$YEAR). Fixing..." + echo "123" | sudo -S date -s "2026-04-10 16:00:00" 2>/dev/null || true +fi + +# ── Setup display ───────────────────────────────────────────────────────────── +if [ "$HEADLESS" = true ]; then + export QT_QPA_PLATFORM=offscreen + HEADLESS_FLAG="--headless" + echo "Mode: HEADLESS (no display, results saved to captures/)" +else + xhost + >/dev/null 2>&1 || true + export DISPLAY=:0 + HEADLESS_FLAG="" + echo "Mode: DISPLAY (OpenCV window on monitor)" +fi + +HALF_FLAG="" +if [ "$HALF" = true ]; then + HALF_FLAG="--half" +fi + +STREAM_FLAG="" +if [ "$STREAM_PORT" -gt 0 ]; then + STREAM_FLAG="--stream $STREAM_PORT" +fi + +echo "============================================" +echo " Saqr PPE Detection - Unitree G1 Robot" +echo "============================================" +echo " Source : $SOURCE" +echo " Model : $MODEL" +echo " Device : $DEVICE (half=$HALF, imgsz=$IMGSZ)" +echo " Conf : $CONF" +echo " Stream : ${STREAM_PORT:-disabled}" +echo " Camera : RealSense D435I" +echo "============================================" +echo "" +echo " Press q to quit, s to save frame." +echo "" + +# ── Run ─────────────────────────────────────────────────────────────────────── +python saqr.py \ + --source "$SOURCE" \ + --model "$MODEL" \ + --conf "$CONF" \ + --max-missing "$MAX_MISSING" \ + --match-distance "$MATCH_DIST" \ + --status-confirm-frames "$CONFIRM" \ + --device "$DEVICE" \ + --imgsz "$IMGSZ" \ + $HALF_FLAG \ + $STREAM_FLAG \ + $HEADLESS_FLAG diff --git a/saqr.py b/saqr.py new file mode 100644 index 0000000..52b0d87 --- /dev/null +++ b/saqr.py @@ -0,0 +1,909 @@ +""" +Saqr - PPE Safety Tracking +=========================== +Real-time PPE monitoring with person tracking. + +Pipeline: + 1. YOLO detection -> PPE bounding boxes (helmet, no-helmet, vest, ...) + 2. Heuristic grouping -> cluster nearby PPE boxes into person candidates + 3. Person tracker -> assign stable IDs across frames + 4. Compliance check -> SAFE / PARTIAL / UNSAFE per person + 5. Auto-capture -> save latest crop per tracked person + 6. CSV logging -> result.csv (current state) + events.csv (audit log) + +Compliance rules (helmet + vest focus): + SAFE = helmet AND vest detected, no violations + PARTIAL = only one of helmet / vest detected + UNSAFE = no-helmet or no-vest detected, or nothing detected + +Usage: + python saqr.py --source 0 # webcam (OpenCV) + python saqr.py --source realsense # Intel RealSense D435I + python saqr.py --source 1 --model models/saqr_best.pt + python saqr.py --source video.mp4 --headless +""" + +from __future__ import annotations + +import argparse +import csv +import math +import shutil +import time +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +import threading +from http.server import HTTPServer, BaseHTTPRequestHandler + +import cv2 +import numpy as np +from ultralytics import YOLO + +from logger import get_logger + +# Optional RealSense support +try: + import pyrealsense2 as rs + HAS_REALSENSE = True +except ImportError: + HAS_REALSENSE = False + +log = get_logger("Inference", "saqr") + +# ── Paths ───────────────────────────────────────────────────────────────────── +ROOT = Path(__file__).resolve().parent +CAPTURES_DIR = ROOT / "captures" +RESULT_CSV = CAPTURES_DIR / "result.csv" +EVENTS_CSV = CAPTURES_DIR / "events.csv" + +# ── Colours ─────────────────────────────────────────────────────────────────── +GREEN = (0, 200, 0) +YELLOW = (0, 200, 255) +RED = (0, 0, 220) +WHITE = (255, 255, 255) +BLACK = (0, 0, 0) +GRAY = (120, 120, 120) +CYAN = (200, 200, 0) + +# ── PPE class definitions ──────────────────────────────────────────────────── +STATUSES = ("SAFE", "PARTIAL", "UNSAFE") + +CLASS_ORDER = [ + "boots", "gloves", "goggles", "helmet", + "no-boots", "no-gloves", "no-goggles", "no-helmet", "no-vest", "vest", +] +PPE_SET = set(CLASS_ORDER) + +# Positive -> Negative mapping +POSITIVE_TO_NEGATIVE = { + "helmet": "no-helmet", + "vest": "no-vest", + "boots": "no-boots", + "gloves": "no-gloves", + "goggles": "no-goggles", +} +PPE_DISPLAY_ORDER = ["helmet", "vest", "gloves", "goggles", "boots"] + + +# ── Data classes ────────────────────────────────────────────────────────────── +@dataclass +class PPEItem: + label: str + conf: float + bbox: Tuple[int, int, int, int] # x1, y1, x2, y2 + + +@dataclass +class PersonCandidate: + bbox: Tuple[int, int, int, int] + items: Dict[str, float] # label -> best confidence + detections: List[PPEItem] = field(default_factory=list) + + +@dataclass +class Track: + track_id: int + bbox: Tuple[int, int, int, int] + items: Dict[str, float] + status: str + last_seen_frame: int = 0 + last_seen_iso: str = "" + created_iso: str = "" + frames_missing: int = 0 + photo_path: Optional[Path] = None + announced_status: Optional[str] = None + event_count: int = 0 + pending_status: Optional[str] = None + pending_count: int = 0 + + +# ── Utilities ───────────────────────────────────────────────────────────────── +def now_iso() -> str: + return datetime.now().isoformat(timespec="seconds") + + +def clamp_bbox(bbox, w, h): + x1, y1, x2, y2 = bbox + return max(0, x1), max(0, y1), min(w, x2), min(h, y2) + + +def expand_bbox(bbox, w, h, sx=0.8, sy=1.5): + x1, y1, x2, y2 = bbox + bw, bh = x2 - x1, y2 - y1 + cx, cy = (x1 + x2) // 2, (y1 + y2) // 2 + nw, nh = int(bw * (1 + sx)), int(bh * (1 + sy)) + nx1 = max(0, cx - nw // 2) + ny1 = max(0, cy - nh // 2) + return nx1, ny1, min(w, nx1 + nw), min(h, ny1 + nh) + + +def merge_boxes(a, b): + return (min(a[0], b[0]), min(a[1], b[1]), max(a[2], b[2]), max(a[3], b[3])) + + +def box_center(bbox): + return ((bbox[0] + bbox[2]) / 2, (bbox[1] + bbox[3]) / 2) + + +def box_distance(a, b) -> float: + ca, cb = box_center(a), box_center(b) + return math.hypot(ca[0] - cb[0], ca[1] - cb[1]) + + +def resolve_model_path(root: Path, model_arg: str) -> Path: + """Find model weights with fallback: arg -> root/arg -> models/arg.""" + p = Path(model_arg) + if p.exists(): + return p + p = root / model_arg + if p.exists(): + return p + p = root / "models" / Path(model_arg).name + if p.exists(): + return p + raise FileNotFoundError(f"Model not found: {model_arg}") + + +# ── Detection ───────────────────────────────────────────────────────────────── +# Global inference config (set by main(), read by collect_detections) +_INFER_KWARGS: Dict = {"device": "cpu", "half": False, "imgsz": 640} + + +def collect_detections(frame, model: YOLO, conf: float) -> List[PPEItem]: + """Run YOLO and return only PPE-class detections.""" + results = model(frame, conf=conf, verbose=False, **_INFER_KWARGS)[0] + items = [] + for box in results.boxes: + cls_id = int(box.cls) + label = model.names[cls_id] + if label not in PPE_SET: + continue + x1, y1, x2, y2 = map(int, box.xyxy[0]) + items.append(PPEItem(label=label, conf=float(box.conf), bbox=(x1, y1, x2, y2))) + return items + + +# ── Grouping: PPE items -> Person candidates ───────────────────────────────── +def should_merge(candidate: PersonCandidate, item: PPEItem) -> bool: + """Heuristic: is this PPE item close enough to belong to the candidate?""" + cx1, cy1, cx2, cy2 = candidate.bbox + ix1, iy1, ix2, iy2 = item.bbox + cw, ch = cx2 - cx1, cy2 - cy1 + iw, ih = ix2 - ix1, iy2 - iy1 + + cxc, cyc = (cx1 + cx2) / 2, (cy1 + cy2) / 2 + ixc, iyc = (ix1 + ix2) / 2, (iy1 + iy2) / 2 + + max_dx = max(cw, iw) * 1.2 + 40 + max_dy = max(ch, ih) * 1.8 + 50 + + return abs(ixc - cxc) <= max_dx and abs(iyc - cyc) <= max_dy + + +def group_detections_to_people(detections: List[PPEItem], w: int, h: int) -> List[PersonCandidate]: + """Cluster PPE detections into person candidates (2-pass merge).""" + if not detections: + return [] + + # Pass 1: greedy grouping + candidates: List[PersonCandidate] = [] + for item in detections: + merged = False + for cand in candidates: + if should_merge(cand, item): + cand.bbox = merge_boxes(cand.bbox, item.bbox) + cand.items[item.label] = max(cand.items.get(item.label, 0.0), item.conf) + cand.detections.append(item) + merged = True + break + if not merged: + candidates.append(PersonCandidate( + bbox=item.bbox, + items={item.label: item.conf}, + detections=[item], + )) + + # Pass 2: merge nearby candidates + again = True + while again: + again = False + merged_list: List[PersonCandidate] = [] + for person in candidates: + matched = False + for prev in merged_list: + pw = prev.bbox[2] - prev.bbox[0] + ph = prev.bbox[3] - prev.bbox[1] + dist = box_distance(prev.bbox, person.bbox) + th = max(pw, ph) * 0.55 + if dist <= th: + prev.bbox = merge_boxes(prev.bbox, person.bbox) + for label, conf in person.items.items(): + prev.items[label] = max(prev.items.get(label, 0.0), conf) + prev.detections.extend(person.detections) + again = True + matched = True + break + if not matched: + merged_list.append(person) + candidates = merged_list + + # Expand each person bbox for better crop coverage + for cand in candidates: + cand.bbox = expand_bbox(cand.bbox, w, h) + + return candidates + + +# ── Status logic (helmet + vest focus) ──────────────────────────────────────── +def status_from_items(items: Dict[str, float]) -> str: + has_helmet = items.get("helmet", 0.0) > items.get("no-helmet", 0.0) and items.get("helmet", 0.0) > 0 + has_vest = items.get("vest", 0.0) > items.get("no-vest", 0.0) and items.get("vest", 0.0) > 0 + no_helmet = items.get("no-helmet", 0.0) > 0 + no_vest = items.get("no-vest", 0.0) > 0 + + if no_helmet or no_vest: + return "UNSAFE" + if has_helmet and has_vest: + return "SAFE" + if has_helmet or has_vest: + return "PARTIAL" + return "UNSAFE" + + +def split_wearing_missing(items: Dict[str, float]) -> Tuple[List[str], List[str], List[str]]: + wearing, missing, unknown = [], [], [] + for pos in PPE_DISPLAY_ORDER: + neg = POSITIVE_TO_NEGATIVE[pos] + pos_conf = items.get(pos, 0.0) + neg_conf = items.get(neg, 0.0) + if pos_conf > neg_conf and pos_conf > 0: + wearing.append(pos) + elif neg_conf >= pos_conf and neg_conf > 0: + missing.append(pos) + else: + unknown.append(pos) + return wearing, missing, unknown + + +# ── CSV Writers ─────────────────────────────────────────────────────────────── +class EventLogger: + FIELDS = ["timestamp", "track_id", "event_type", "status", + "wearing", "missing", "unknown", "photo", "path"] + + def __init__(self, path: Path): + self.path = path + self.path.parent.mkdir(parents=True, exist_ok=True) + if not self.path.exists(): + with open(self.path, "w", newline="", encoding="utf-8") as f: + csv.DictWriter(f, fieldnames=self.FIELDS).writeheader() + + def append(self, row: Dict[str, str]) -> None: + with open(self.path, "a", newline="", encoding="utf-8") as f: + csv.DictWriter(f, fieldnames=self.FIELDS).writerow(row) + + +def write_result_csv(tracks: List[Track], output: Path) -> None: + output.parent.mkdir(parents=True, exist_ok=True) + fields = ["photo", "track_id", "status", "last_seen", + "wearing", "missing", "unknown", *CLASS_ORDER, "path"] + rows = [] + for track in sorted(tracks, key=lambda t: t.track_id): + wearing, missing, unknown = split_wearing_missing(track.items) + row = { + "photo": track.photo_path.name if track.photo_path else "", + "track_id": track.track_id, + "status": track.status, + "last_seen": track.last_seen_iso, + "wearing": ", ".join(wearing), + "missing": ", ".join(missing), + "unknown": ", ".join(unknown), + "path": str(track.photo_path) if track.photo_path else "", + } + for cls in CLASS_ORDER: + row[cls] = 1 if track.items.get(cls, 0.0) > 0 else 0 + rows.append(row) + + with open(output, "w", newline="", encoding="utf-8") as f: + w = csv.DictWriter(f, fieldnames=fields) + w.writeheader() + w.writerows(rows) + + +# ── Person Tracker ──────────────────────────────────────────────────────────── +class PersonTracker: + def __init__( + self, + event_logger: EventLogger, + max_missing: int = 90, + match_distance: float = 250.0, + status_confirm_frames: int = 5, + ): + self.event_logger = event_logger + self.max_missing = max_missing + self.match_distance = match_distance + self.status_confirm_frames = max(1, status_confirm_frames) + self.tracks: Dict[int, Track] = {} + self.next_id = 1 + + def _new_track(self, person: PersonCandidate, frame_idx: int) -> Track: + track = Track( + track_id=self.next_id, + bbox=person.bbox, + items=dict(person.items), + status=status_from_items(person.items), + last_seen_frame=frame_idx, + last_seen_iso=now_iso(), + created_iso=now_iso(), + ) + self.next_id += 1 + self.tracks[track.track_id] = track + return track + + def _match(self, person: PersonCandidate, used: set[int]) -> Optional[Track]: + best, best_dist = None, float("inf") + for tid, track in self.tracks.items(): + if tid in used: + continue + dist = box_distance(track.bbox, person.bbox) + if dist < best_dist and dist <= self.match_distance: + best_dist = dist + best = track + return best + + def update(self, people: List[PersonCandidate], frame_idx: int): + used: set[int] = set() + created: List[Track] = [] + changed: List[Track] = [] + + for person in people: + track = self._match(person, used) + if track is None: + track = self._new_track(person, frame_idx) + created.append(track) + else: + new_status = status_from_items(person.items) + track.bbox = person.bbox + track.items = dict(person.items) + track.last_seen_frame = frame_idx + track.last_seen_iso = now_iso() + track.frames_missing = 0 + + if new_status != track.status: + if track.pending_status == new_status: + track.pending_count += 1 + else: + track.pending_status = new_status + track.pending_count = 1 + if track.pending_count >= self.status_confirm_frames: + track.status = new_status + track.pending_status = None + track.pending_count = 0 + changed.append(track) + else: + track.pending_status = None + track.pending_count = 0 + + used.add(track.track_id) + + # Age and prune missing tracks + stale = [] + for tid, track in self.tracks.items(): + if tid not in used: + track.frames_missing += 1 + if track.frames_missing > self.max_missing: + stale.append(tid) + for tid in stale: + del self.tracks[tid] + + return created, changed + + def visible_tracks(self) -> List[Track]: + return [t for t in self.tracks.values() if t.frames_missing == 0] + + +# ── Track image + event ─────────────────────────────────────────────────────── +def save_track_image(frame, track: Track, capture_dirs: Dict[str, Path]) -> Optional[Path]: + h, w = frame.shape[:2] + x1, y1, x2, y2 = clamp_bbox(track.bbox, w, h) + if x2 <= x1 or y2 <= y1: + return None + crop = frame[y1:y2, x1:x2] + if crop.size == 0: + return None + + target = capture_dirs[track.status] / f"track_{track.track_id:04d}.jpg" + # Move old image if status folder changed + if track.photo_path and track.photo_path != target and track.photo_path.exists(): + try: + track.photo_path.unlink() + except OSError: + pass + + cv2.imwrite(str(target), crop) + track.photo_path = target + return target + + +def emit_event( + track: Track, + event_logger: EventLogger, + event_type: str = "STATUS_CHANGE", + force: bool = False, +) -> None: + if track.photo_path is None: + return + if not force and track.announced_status == track.status: + return + + wearing, missing, unknown = split_wearing_missing(track.items) + msg = ( + f"ID {track.track_id:04d} | {event_type} | {track.status} | " + f"wearing: {', '.join(wearing) or 'none'} | " + f"missing: {', '.join(missing) or 'none'} | " + f"unknown: {', '.join(unknown) or 'none'}" + ) + print(msg, flush=True) + + event_logger.append({ + "timestamp": now_iso(), + "track_id": str(track.track_id), + "event_type": event_type, + "status": track.status, + "wearing": ", ".join(wearing), + "missing": ", ".join(missing), + "unknown": ", ".join(unknown), + "photo": track.photo_path.name if track.photo_path else "", + "path": str(track.photo_path) if track.photo_path else "", + }) + track.announced_status = track.status + track.event_count += 1 + + +# ── Drawing ─────────────────────────────────────────────────────────────────── +def status_color(status: str) -> Tuple: + return {"SAFE": GREEN, "PARTIAL": YELLOW, "UNSAFE": RED}.get(status, GRAY) + + +def draw_track(frame, track: Track): + x1, y1, x2, y2 = track.bbox + color = status_color(track.status) + cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2) + + wearing, missing, unknown = split_wearing_missing(track.items) + line1 = f"ID {track.track_id:04d} {track.status}" + w_str = ", ".join(wearing) if wearing else "none" + m_str = ", ".join(missing) if missing else "-" + line2 = f"W:{w_str} M:{m_str}" + + (tw1, th1), _ = cv2.getTextSize(line1, cv2.FONT_HERSHEY_SIMPLEX, 0.55, 1) + (tw2, th2), _ = cv2.getTextSize(line2, cv2.FONT_HERSHEY_SIMPLEX, 0.40, 1) + tw = max(tw1, tw2) + 8 + total_h = th1 + th2 + 12 + y_top = max(0, y1 - total_h - 2) + + cv2.rectangle(frame, (x1, y_top), (x1 + tw, y1), color, -1) + cv2.putText(frame, line1, (x1 + 4, y_top + th1 + 2), + cv2.FONT_HERSHEY_SIMPLEX, 0.55, WHITE, 1, cv2.LINE_AA) + cv2.putText(frame, line2, (x1 + 4, y_top + th1 + th2 + 8), + cv2.FONT_HERSHEY_SIMPLEX, 0.40, WHITE, 1, cv2.LINE_AA) + + +def draw_counters(frame, tracks: List[Track], fps: float): + counts = {s: 0 for s in STATUSES} + for t in tracks: + counts[t.status] += 1 + + lines = [ + (f"FPS: {fps:.1f}", WHITE), + (f"SAFE {counts['SAFE']}", GREEN), + (f"PARTIAL {counts['PARTIAL']}", YELLOW), + (f"UNSAFE {counts['UNSAFE']}", RED), + (f"TRACKS {len(tracks)}", CYAN), + ] + y = 24 + for text, color in lines: + cv2.putText(frame, text, (10, y), cv2.FONT_HERSHEY_SIMPLEX, 0.7, BLACK, 4, cv2.LINE_AA) + cv2.putText(frame, text, (10, y), cv2.FONT_HERSHEY_SIMPLEX, 0.7, color, 2, cv2.LINE_AA) + y += 28 + + +# ── Frame processing ────────────────────────────────────────────────────────── +def process_frame( + frame, + model: YOLO, + tracker: PersonTracker, + frame_idx: int, + conf: float, + capture_dirs: Dict[str, Path], + write_csv: bool = True, +): + annotated = frame.copy() + h, w = annotated.shape[:2] + + detections = collect_detections(frame, model, conf) + candidates = group_detections_to_people(detections, w, h) + created, changed = tracker.update(candidates, frame_idx) + visible = tracker.visible_tracks() + + created_ids = {t.track_id for t in created} + changed_ids = {t.track_id for t in changed} + event_ids = created_ids | changed_ids + + for track in visible: + save_track_image(frame, track, capture_dirs) + if track.track_id in event_ids: + ev_type = "NEW" if track.track_id in created_ids else "STATUS_CHANGE" + emit_event(track, tracker.event_logger, ev_type) + draw_track(annotated, track) + + if write_csv: + write_result_csv(list(tracker.tracks.values()), RESULT_CSV) + + return annotated, visible + + +# ── MJPEG Stream Server (view on laptop browser) ───────────────────────────── +_stream_frame: Optional[bytes] = None +_stream_lock = threading.Lock() + + +class MJPEGHandler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/": + self.send_response(200) + self.send_header("Content-Type", "text/html") + self.end_headers() + self.wfile.write(b'' + b'' + b'') + elif self.path == "/stream": + self.send_response(200) + self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame") + self.end_headers() + while True: + with _stream_lock: + jpeg = _stream_frame + if jpeg is None: + time.sleep(0.03) + continue + try: + self.wfile.write(b"--frame\r\n" + b"Content-Type: image/jpeg\r\n\r\n" + jpeg + b"\r\n") + except BrokenPipeError: + break + else: + self.send_error(404) + + def log_message(self, format, *args): + pass # silence per-request logs + + +def start_stream_server(port: int = 8080): + server = HTTPServer(("0.0.0.0", port), MJPEGHandler) + t = threading.Thread(target=server.serve_forever, daemon=True) + t.start() + log.info(f"MJPEG stream server started on http://0.0.0.0:{port}") + return server + + +def update_stream_frame(frame): + global _stream_frame + _, jpeg = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 70]) + with _stream_lock: + _stream_frame = jpeg.tobytes() + + +# ── Camera / video ──────────────────────────────────────────────────────────── +class RealSenseCapture: + """Wraps pyrealsense2 pipeline with an OpenCV-like read() interface.""" + + def __init__(self, width: int = 640, height: int = 480, fps: int = 30, + serial: Optional[str] = None): + if not HAS_REALSENSE: + raise RuntimeError("pyrealsense2 not installed") + self.pipeline = rs.pipeline() + cfg = rs.config() + if serial: + cfg.enable_device(serial) + cfg.enable_stream(rs.stream.color, width, height, rs.format.bgr8, fps) + self.profile = self.pipeline.start(cfg) + self._open = True + dev = self.profile.get_device() + log.info(f"RealSense opened | {dev.get_info(rs.camera_info.name)} " + f"serial={dev.get_info(rs.camera_info.serial_number)} " + f"{width}x{height}@{fps}") + + def isOpened(self) -> bool: + return self._open + + def read(self): + if not self._open: + return False, None + try: + frames = self.pipeline.wait_for_frames(timeout_ms=3000) + color = frames.get_color_frame() + if not color: + return False, None + return True, np.asanyarray(color.get_data()) + except Exception: + return False, None + + def release(self): + if self._open: + self.pipeline.stop() + self._open = False + + +def open_capture(source: str): + # RealSense source: "realsense" or "realsense:SERIAL" + if source.lower().startswith("realsense"): + serial = None + if ":" in source: + serial = source.split(":", 1)[1] + return RealSenseCapture(width=640, height=480, fps=30, serial=serial) + + if str(source).isdigit(): + idx = int(source) + cap = cv2.VideoCapture(idx) + if cap.isOpened(): + return cap + cap = cv2.VideoCapture(idx, cv2.CAP_ANY) + if cap.isOpened(): + return cap + cap = cv2.VideoCapture(idx, cv2.CAP_V4L2) + return cap + + # V4L2 device path + if source.startswith("/dev/video"): + cap = cv2.VideoCapture(source, cv2.CAP_V4L2) + if cap.isOpened(): + cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) + cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"MJPG")) + cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) + cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) + cap.set(cv2.CAP_PROP_FPS, 30) + return cap + + return cv2.VideoCapture(source) + + +def setup_capture_dirs(base: Path) -> Dict[str, Path]: + dirs = {} + for s in STATUSES: + d = base / "captures" / s + d.mkdir(parents=True, exist_ok=True) + dirs[s] = d + return dirs + + +def run_video( + model: YOLO, + source: str, + conf: float, + capture_dirs: Dict[str, Path], + show_gui: bool, + csv_every_frame: bool, + max_missing: int, + match_distance: float, + status_confirm_frames: int, + stream_port: int = 0, +) -> None: + cap = open_capture(source) + if not cap.isOpened(): + log.error(f"Cannot open source: {source}") + return + + ok, first = cap.read() + if not ok or first is None or first.size == 0: + log.error(f"Cannot read first frame from source: {source}") + cap.release() + return + + event_logger = EventLogger(EVENTS_CSV) + tracker = PersonTracker( + event_logger=event_logger, + max_missing=max_missing, + match_distance=match_distance, + status_confirm_frames=status_confirm_frames, + ) + + # Start MJPEG stream server if requested + if stream_port > 0: + start_stream_server(stream_port) + + log.info(f"Session started | source={source}") + if show_gui: + print("Running - press q to quit, s to save frame.") + + prev = time.time() + frame_idx = 0 + frame = first + + while True: + frame_idx += 1 + try: + annotated, visible = process_frame( + frame, model, tracker, frame_idx, conf, + capture_dirs, write_csv=csv_every_frame, + ) + except Exception as e: + log.exception(f"Frame error #{frame_idx}: {e}") + annotated = frame + visible = tracker.visible_tracks() + + now_t = time.time() + fps = 1.0 / max(now_t - prev, 1e-9) + prev = now_t + + draw_counters(annotated, visible, fps) + + # Send to stream + if stream_port > 0: + update_stream_frame(annotated) + + if show_gui: + cv2.imshow("Saqr PPE Tracking", annotated) + key = cv2.waitKey(1) & 0xFF + if key == ord("q"): + break + if key == ord("s"): + cv2.imwrite("saved_frame.jpg", annotated) + log.info("Frame saved: saved_frame.jpg") + + ret, frame = cap.read() + if not ret: + break + + cap.release() + if show_gui: + cv2.destroyAllWindows() + + # Final CSV write + write_result_csv(list(tracker.tracks.values()), RESULT_CSV) + log.info(f"Session ended | frames={frame_idx} tracks_created={tracker.next_id - 1}") + + +def run_image(model: YOLO, path: str, conf: float, capture_dirs: Dict[str, Path], show_gui: bool): + frame = cv2.imread(path) + if frame is None: + log.error(f"Cannot read image: {path}") + return + + event_logger = EventLogger(EVENTS_CSV) + tracker = PersonTracker(event_logger=event_logger) + + annotated, visible = process_frame(frame, model, tracker, 1, conf, capture_dirs) + draw_counters(annotated, visible, 0.0) + + out = Path(path).stem + "_saqr.jpg" + cv2.imwrite(out, annotated) + log.info(f"Result saved: {out}") + + if show_gui: + cv2.imshow("Saqr PPE Tracking", annotated) + cv2.waitKey(0) + cv2.destroyAllWindows() + + +# ── CLI ─────────────────────────────────────────────────────────────────────── +def main(): + parser = argparse.ArgumentParser(description="Saqr PPE detection with tracking") + parser.add_argument("--source", default="0", + help="0/1=webcam, realsense, realsense:SERIAL, /dev/videoX, or video path") + parser.add_argument("--model", default="models/saqr_best.pt", + help="Trained YOLO weights") + parser.add_argument("--conf", type=float, default=0.35, + help="Detection confidence threshold") + parser.add_argument("--max-missing", type=int, default=90, + help="Frames to keep a lost track alive") + parser.add_argument("--match-distance", type=float, default=250.0, + help="Max pixel distance for track matching") + parser.add_argument("--status-confirm-frames", type=int, default=5, + help="Frames needed to confirm a status change") + parser.add_argument("--headless", action="store_true", + help="Disable OpenCV GUI window") + parser.add_argument("--stream", type=int, default=0, metavar="PORT", + help="Start MJPEG stream on this port (e.g. --stream 8080)") + parser.add_argument("--csv-on-exit", action="store_true", + help="Write result.csv only at session end") + # GPU / inference tuning + parser.add_argument("--device", default="0", + help="Device: 'cpu', '0' (first GPU), 'cuda:0', etc.") + parser.add_argument("--half", action="store_true", + help="Enable FP16 inference (Jetson / RTX GPUs)") + parser.add_argument("--imgsz", type=int, default=320, + help="Inference image size (320 fast, 640 accurate)") + args = parser.parse_args() + + # ── Configure global inference kwargs ──────────────────────────────── + global _INFER_KWARGS + _INFER_KWARGS = { + "device": args.device, + "half": args.half, + "imgsz": args.imgsz, + } + + # ── Log CUDA status ────────────────────────────────────────────────── + try: + import torch + if torch.cuda.is_available(): + dev_name = torch.cuda.get_device_name(0) + log.info(f"CUDA available: {dev_name} | torch={torch.__version__} | " + f"cuda={torch.version.cuda}") + else: + log.warning("CUDA not available - running on CPU (slow)") + if args.device != "cpu": + log.warning(f"Falling back to CPU (you requested device={args.device})") + _INFER_KWARGS["device"] = "cpu" + _INFER_KWARGS["half"] = False + except ImportError: + log.warning("PyTorch not found") + + log.info(f"Inference config: device={_INFER_KWARGS['device']} " + f"half={_INFER_KWARGS['half']} imgsz={_INFER_KWARGS['imgsz']}") + + capture_dirs = setup_capture_dirs(ROOT) + + try: + model_path = resolve_model_path(ROOT, args.model) + except FileNotFoundError as e: + log.error(str(e)) + log.error("Train first: python train.py --dataset dataset") + raise SystemExit(1) + + log.info(f"Loading model: {model_path}") + model = YOLO(str(model_path)) + log.info(f"Classes: {list(model.names.values())}") + + source = args.source + is_live = ( + source.isdigit() + or source.lower().startswith("realsense") + or source.startswith("/dev/video") + ) + is_video_file = source.lower().endswith( + (".mp4", ".avi", ".mov", ".mkv", ".webm") + ) + + if is_live or is_video_file: + run_video( + model, source, args.conf, capture_dirs, + show_gui=not args.headless, + csv_every_frame=not args.csv_on_exit, + max_missing=args.max_missing, + match_distance=args.match_distance, + status_confirm_frames=args.status_confirm_frames, + stream_port=args.stream, + ) + elif Path(source).exists(): + run_image(model, source, args.conf, capture_dirs, show_gui=not args.headless) + else: + log.error(f"Source not found: {source}") + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/saqr_g1_bridge.py b/saqr_g1_bridge.py new file mode 100644 index 0000000..f95089b --- /dev/null +++ b/saqr_g1_bridge.py @@ -0,0 +1,376 @@ +#!/usr/bin/env python3 +""" +saqr_g1_bridge.py + +Bridge between Saqr PPE detection and the Unitree G1 robot. + +Spawns Saqr (saqr.py in this same folder) as a subprocess, parses its event +stream, and on each per-person status transition: + + * UNSAFE -> announce "Not safe!" via the G1 onboard TtsMaker (English, + speaker_id=2) AND run the 'reject' arm action (id=13). + * SAFE -> announce "Safe!" via the G1 onboard TtsMaker. No arm motion. + * PARTIAL -> nothing. + +Both DDS clients (G1ArmActionClient and G1 AudioClient) share a single +ChannelFactoryInitialize call. The TTS speaker_id was identified by running +Project/Sanad/voice_example.py mode 6 — speaker_id=2 is English on current +G1 firmware (speaker_id=0 is Chinese regardless of input text). + +Saqr event line format (from emit_event in saqr.py): + ID 0001 | NEW | UNSAFE | wearing: ... | missing: ... | unknown: ... + ID 0001 | STATUS_CHANGE | SAFE | wearing: ... | missing: ... | unknown: ... + +Usage: + # default: webcam, default DDS interface + python3 saqr_g1_bridge.py + + # on the robot + python3 saqr_g1_bridge.py --iface eth0 --source realsense --headless + + # dry run (no robot movement / TTS, just print decisions) + python3 saqr_g1_bridge.py --dry-run + + # forward extra args to saqr.py after a `--` + python3 saqr_g1_bridge.py --iface eth0 -- --conf 0.4 --imgsz 640 +""" + +from __future__ import annotations + +import argparse +import os +import re +import signal +import subprocess +import sys +import threading +import time +from pathlib import Path +from typing import Dict, Optional + + +# ── Defaults ───────────────────────────────────────────────────────────────── +HERE = Path(__file__).resolve().parent +SAQR_DIR = HERE # bridge lives next to saqr.py +SAQR_SCRIPT = SAQR_DIR / "saqr.py" + +DANGER_STATUS = "UNSAFE" +SAFE_STATUS = "SAFE" +REJECT_ACTION = "reject" +RELEASE_ACTION = "release arm" + +# G1 onboard TtsMaker (see Project/Sanad/voice_example.py mode 6). +# speaker_id=2 was confirmed English on current G1 firmware. +TTS_SPEAKER_ID = 2 +TTS_VOLUME = 100 + +TTS_TEXT_SAFE = "Safe to enter. Have a good day." +TTS_UNSAFE_WITH_MISSING = ( + "Please stop. Wear your proper safety equipment. You are missing {items}." +) +TTS_UNSAFE_GENERIC = ( + "Please stop. Wear your proper safety equipment." +) + +# ID NNNN | EVENT_TYPE | STATUS | wearing: ... | missing: ... | unknown: ... +EVENT_RE = re.compile( + r"^ID\s+(?P\d+)\s*\|\s*" + r"(?PNEW|STATUS_CHANGE)\s*\|\s*" + r"(?PSAFE|PARTIAL|UNSAFE)\s*\|\s*" + r"wearing:\s*(?P[^|]*?)\s*\|\s*" + r"missing:\s*(?P[^|]*?)\s*\|\s*" + r"unknown:\s*(?P.*?)\s*$" +) + + +def _parse_list_field(s: str) -> list: + """Parse 'helmet, vest' or 'none' into a list of items.""" + s = (s or "").strip() + if not s or s.lower() == "none": + return [] + return [x.strip() for x in s.split(",") if x.strip()] + + +def _human_join(items: list) -> str: + """Join a list in natural English: 'helmet and vest', 'a, b, and c'.""" + if not items: + return "" + if len(items) == 1: + return items[0] + if len(items) == 2: + return f"{items[0]} and {items[1]}" + return ", ".join(items[:-1]) + f", and {items[-1]}" + + +def build_unsafe_tts(missing: list) -> str: + if not missing: + return TTS_UNSAFE_GENERIC + return TTS_UNSAFE_WITH_MISSING.format(items=_human_join(missing)) + + +# ── G1 robot controller (lazy import: SDK only loaded when not in dry-run) ─── +class RobotController: + """Owns both the G1 arm action client and the G1 audio (TTS) client. + + A single ChannelFactoryInitialize call is shared by both clients. + """ + + def __init__( + self, + iface: Optional[str], + timeout: float, + dry_run: bool, + tts_speaker_id: int, + ): + self.dry_run = dry_run + self.tts_speaker_id = tts_speaker_id + self.arm_client = None + self.audio_client = None + self._action_map = None + + if dry_run: + print("[BRIDGE] DRY RUN — G1 SDK will not be loaded.", flush=True) + return + + from unitree_sdk2py.core.channel import ChannelFactoryInitialize + from unitree_sdk2py.g1.arm.g1_arm_action_client import ( + G1ArmActionClient, + action_map, + ) + from unitree_sdk2py.g1.audio.g1_audio_client import AudioClient + + self._action_map = action_map + + if iface: + ChannelFactoryInitialize(0, iface) + else: + ChannelFactoryInitialize(0) + + self.arm_client = G1ArmActionClient() + self.arm_client.SetTimeout(timeout) + self.arm_client.Init() + print(f"[BRIDGE] G1ArmActionClient ready (iface={iface or 'default'})", + flush=True) + + self.audio_client = AudioClient() + self.audio_client.SetTimeout(timeout) + self.audio_client.Init() + try: + self.audio_client.SetVolume(TTS_VOLUME) + except Exception as e: + print(f"[BRIDGE][WARN] AudioClient.SetVolume failed: {e}", flush=True) + print(f"[BRIDGE] G1 AudioClient ready (speaker_id={tts_speaker_id})", + flush=True) + + # ── TTS ───────────────────────────────────────────────────────────────── + def speak(self, text: str): + if self.dry_run: + print(f"[BRIDGE] (dry) would TtsMaker({text!r}, " + f"speaker_id={self.tts_speaker_id})", flush=True) + return + if self.audio_client is None: + return + try: + print(f"[BRIDGE] tts -> {text!r}", flush=True) + code = self.audio_client.TtsMaker(text, self.tts_speaker_id) + if code != 0: + print(f"[BRIDGE][WARN] TtsMaker return code = {code}", flush=True) + except Exception as e: + print(f"[BRIDGE][ERR] TtsMaker failed: {e}", flush=True) + + # ── Arm ───────────────────────────────────────────────────────────────── + def reject(self, release_after: float): + if self.dry_run: + print(f"[BRIDGE] (dry) would run '{REJECT_ACTION}' " + f"then release after {release_after:.1f}s", flush=True) + return + if self.arm_client is None or self._action_map is None: + return + if REJECT_ACTION not in self._action_map: + print(f"[BRIDGE][ERR] '{REJECT_ACTION}' not in SDK action_map", + flush=True) + return + print(f"[BRIDGE] -> {REJECT_ACTION}", flush=True) + self.arm_client.ExecuteAction(self._action_map[REJECT_ACTION]) + if release_after > 0: + time.sleep(release_after) + print(f"[BRIDGE] -> {RELEASE_ACTION}", flush=True) + self.arm_client.ExecuteAction(self._action_map[RELEASE_ACTION]) + + +# ── Bridge ─────────────────────────────────────────────────────────────────── +class Bridge: + def __init__( + self, + robot: RobotController, + cooldown_s: float, + release_after_s: float, + ): + self.robot = robot + self.cooldown_s = cooldown_s + self.release_after_s = release_after_s + self.last_status: Dict[int, str] = {} + # Per-id cooldown is keyed by (track_id, status) so a SAFE announce + # and an UNSAFE announce don't share the same timer. + self.last_trigger_t: Dict[tuple[int, str], float] = {} + self._lock = threading.Lock() + + def handle_line(self, line: str): + line = line.rstrip() + if not line: + return + # Always echo Saqr output so the user still sees the live stream. + print(line, flush=True) + + m = EVENT_RE.match(line) + if not m: + return + + track_id = int(m.group("id")) + status = m.group("status") + missing = _parse_list_field(m.group("missing")) + + with self._lock: + prev = self.last_status.get(track_id) + self.last_status[track_id] = status + + # Only SAFE / UNSAFE transitions trigger the robot. PARTIAL is silent. + if status not in (DANGER_STATUS, SAFE_STATUS): + return + + # Only fire on transitions, not on every NEW/STATUS_CHANGE for the + # same status. + if prev == status: + return + + now = time.time() + last_t = self.last_trigger_t.get((track_id, status), 0.0) + if (now - last_t) < self.cooldown_s: + return + self.last_trigger_t[(track_id, status)] = now + + # Run robot actions outside the lock so we don't block parsing. + try: + if status == DANGER_STATUS: + self.robot.speak(build_unsafe_tts(missing)) + self.robot.reject(release_after=self.release_after_s) + else: # SAFE + self.robot.speak(TTS_TEXT_SAFE) + except Exception as e: + print(f"[BRIDGE][ERR] robot action failed: {e}", flush=True) + + +# ── Saqr subprocess management ─────────────────────────────────────────────── +def build_saqr_cmd(saqr_extra_args: list[str]) -> list[str]: + if not SAQR_SCRIPT.exists(): + sys.exit(f"[BRIDGE][FATAL] saqr.py not found at: {SAQR_SCRIPT}") + # -u for unbuffered stdout (so events arrive line-by-line). + return [sys.executable, "-u", str(SAQR_SCRIPT), *saqr_extra_args] + + +def split_argv(argv: list[str]) -> tuple[list[str], list[str]]: + """Split bridge args from saqr passthrough args at the first '--'.""" + if "--" in argv: + idx = argv.index("--") + return argv[:idx], argv[idx + 1 :] + return argv, [] + + +def main(): + bridge_argv, saqr_extra = split_argv(sys.argv[1:]) + + ap = argparse.ArgumentParser( + description="Bridge Saqr PPE events to the G1 arm 'reject' action." + ) + ap.add_argument("--iface", default=None, + help="DDS network interface (e.g. enp3s0). Optional.") + ap.add_argument("--timeout", type=float, default=10.0, + help="G1 arm client timeout (seconds).") + ap.add_argument("--cooldown", type=float, default=8.0, + help="Per-track-id seconds before reject can re-trigger.") + ap.add_argument("--release-after", type=float, default=2.0, + help="Seconds before auto-running 'release arm' (0 = never).") + ap.add_argument("--dry-run", action="store_true", + help="Parse and decide but never call the SDK.") + ap.add_argument("--speaker-id", type=int, default=TTS_SPEAKER_ID, + help=f"G1 TtsMaker speaker_id (default {TTS_SPEAKER_ID}, English).") + + # Convenience pass-throughs to saqr.py (you can also use `-- ...`). + ap.add_argument("--source", default=None, + help="Saqr --source (0/realsense/path). Default: leave to saqr.") + ap.add_argument("--headless", action="store_true", + help="Pass --headless to saqr.") + ap.add_argument("--saqr-conf", type=float, default=None, + help="Pass --conf to saqr.") + ap.add_argument("--imgsz", type=int, default=None, + help="Pass --imgsz to saqr.") + ap.add_argument("--device", default=None, + help="Pass --device to saqr (e.g. cpu / 0 / cuda:0).") + + args = ap.parse_args(bridge_argv) + + # Build saqr args from convenience flags + raw passthrough. + saqr_args: list[str] = [] + if args.source is not None: + saqr_args += ["--source", args.source] + if args.headless: + saqr_args += ["--headless"] + if args.saqr_conf is not None: + saqr_args += ["--conf", str(args.saqr_conf)] + if args.imgsz is not None: + saqr_args += ["--imgsz", str(args.imgsz)] + if args.device is not None: + saqr_args += ["--device", args.device] + saqr_args += saqr_extra + + robot = RobotController( + iface=args.iface, + timeout=args.timeout, + dry_run=args.dry_run, + tts_speaker_id=args.speaker_id, + ) + bridge = Bridge( + robot=robot, + cooldown_s=args.cooldown, + release_after_s=args.release_after, + ) + + cmd = build_saqr_cmd(saqr_args) + print(f"[BRIDGE] launching: {' '.join(cmd)}", flush=True) + print(f"[BRIDGE] cwd: {SAQR_DIR}", flush=True) + + env = os.environ.copy() + env["PYTHONUNBUFFERED"] = "1" + + proc = subprocess.Popen( + cmd, + cwd=str(SAQR_DIR), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, + text=True, + env=env, + ) + + def _forward_signal(signum, _frame): + print(f"[BRIDGE] signal {signum} -> stopping saqr", flush=True) + try: + proc.send_signal(signum) + except Exception: + pass + + signal.signal(signal.SIGINT, _forward_signal) + signal.signal(signal.SIGTERM, _forward_signal) + + try: + assert proc.stdout is not None + for line in proc.stdout: + bridge.handle_line(line) + finally: + rc = proc.wait() + print(f"[BRIDGE] saqr exited rc={rc}", flush=True) + sys.exit(rc) + + +if __name__ == "__main__": + main() diff --git a/train.py b/train.py new file mode 100644 index 0000000..a3b159a --- /dev/null +++ b/train.py @@ -0,0 +1,118 @@ +""" +Saqr - PPE Detection | Training Script +========================================= +Train YOLO11n object detection on a PPE dataset (10 classes). + +Classes: helmet, no-helmet, vest, no-vest, boots, no-boots, + gloves, no-gloves, goggles, no-goggles + +Usage: + python train.py --dataset dataset + python train.py --dataset dataset --epochs 50 --batch 8 +""" + +import argparse +import shutil +from pathlib import Path + +import yaml + +from logger import get_logger + +log = get_logger("Training", "train") + +EXPECTED_CLASSES = [ + "boots", "gloves", "goggles", "helmet", "no-boots", + "no-gloves", "no-goggles", "no-helmet", "no-vest", "vest", +] + + +def fix_data_yaml(dataset_root: Path) -> Path: + """Ensure data.yaml has correct absolute paths for each split.""" + yaml_path = dataset_root / "data.yaml" + if not yaml_path.exists(): + log.error(f"data.yaml not found at {yaml_path}") + raise SystemExit(1) + + with open(yaml_path) as f: + cfg = yaml.safe_load(f) + + changed = False + for key, subdir in [("train", "train"), ("val", "valid"), ("test", "test")]: + img_dir = dataset_root / subdir / "images" + if img_dir.exists() and cfg.get(key) != str(img_dir): + cfg[key] = str(img_dir) + changed = True + + if "path" not in cfg or cfg["path"] != str(dataset_root): + cfg["path"] = str(dataset_root) + changed = True + + if changed: + with open(yaml_path, "w") as f: + yaml.dump(cfg, f, default_flow_style=False) + log.info(f"Fixed data.yaml paths -> {yaml_path}") + + log.info(f"Classes ({cfg.get('nc', '?')}): {cfg.get('names', [])}") + return yaml_path + + +def main(): + parser = argparse.ArgumentParser(description="Train Saqr PPE detector (YOLO11n)") + parser.add_argument("--dataset", default="dataset", + help="Root folder containing data.yaml + train/valid/test") + parser.add_argument("--epochs", type=int, default=100) + parser.add_argument("--imgsz", type=int, default=640) + parser.add_argument("--batch", type=int, default=16) + parser.add_argument("--model", default="yolo11n.pt", + help="Base YOLO model (auto-downloaded if not present)") + parser.add_argument("--name", default="saqr_det") + parser.add_argument("--device", default="0", + help="Training device: 'cpu', '0', 'cuda:0', etc.") + args = parser.parse_args() + + root = Path(__file__).parent + dataset_root = root / args.dataset + if not dataset_root.exists(): + log.error(f"Dataset folder not found: {dataset_root}") + raise SystemExit(1) + + yaml_path = fix_data_yaml(dataset_root) + + from ultralytics import YOLO + + log.info(f"Loading base model: {args.model}") + model = YOLO(args.model) + + log.info(f"Training | epochs={args.epochs} imgsz={args.imgsz} " + f"batch={args.batch} device={args.device}") + model.train( + data=str(yaml_path), + epochs=args.epochs, + imgsz=args.imgsz, + batch=args.batch, + device=args.device, + name=args.name, + project=str(root / "runs" / "train"), + exist_ok=True, + ) + + # Copy best/last weights to models/ + models_dir = root / "models" + models_dir.mkdir(exist_ok=True) + weights_dir = root / "runs" / "train" / args.name / "weights" + + for name in ("best.pt", "last.pt"): + src = weights_dir / name + dst = models_dir / f"saqr_{name}" + if src.exists(): + shutil.copy(src, dst) + log.info(f"Saved: {dst}") + + metrics = model.val() + log.info(f"mAP50={metrics.box.map50:.4f} mAP50-95={metrics.box.map:.4f}") + log.info("Next: python saqr.py --source 0") + + +if __name__ == "__main__": + main() diff --git a/use case catalogue.pdf b/use case catalogue.pdf new file mode 100644 index 0000000000000000000000000000000000000000..d35aea13a0282dab2262c807bb08a281349381da GIT binary patch literal 140482 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 literal 0 HcmV?d00001 diff --git a/view_stream.py b/view_stream.py new file mode 100644 index 0000000..967b705 --- /dev/null +++ b/view_stream.py @@ -0,0 +1,48 @@ +""" +Saqr - View robot PPE stream on laptop via OpenCV +=================================================== +Connects to the robot's MJPEG stream and displays in an OpenCV window. + +Usage: + python view_stream.py + python view_stream.py --ip 192.168.123.164 + python view_stream.py --ip 10.255.254.86 --port 8080 +""" + +import argparse +import cv2 + +def main(): + parser = argparse.ArgumentParser(description="View Saqr PPE stream from robot") + parser.add_argument("--ip", default="192.168.123.164", help="Robot IP address") + parser.add_argument("--port", default="8080", help="Stream port") + args = parser.parse_args() + + url = f"http://{args.ip}:{args.port}/stream" + print(f"Connecting to {url} ...") + + cap = cv2.VideoCapture(url) + if not cap.isOpened(): + print(f"[ERROR] Cannot connect to {url}") + print(f" Try: python view_stream.py --ip 10.255.254.86") + return + + print("Connected! Press q to quit.") + + while True: + ret, frame = cap.read() + if not ret: + print("Stream lost, reconnecting...") + cap.release() + cap = cv2.VideoCapture(url) + continue + + cv2.imshow("Saqr PPE - Robot Stream", frame) + if cv2.waitKey(1) & 0xFF == ord("q"): + break + + cap.release() + cv2.destroyAllWindows() + +if __name__ == "__main__": + main()