Skill: 13681882136/kami-package-detection (version 1.0.9)

--- name: kami-package-detection description: A free skill by Kami SmartHome. Get notified the moment a package arrives at your door. Detects packages, parcels, and bags from RTSP camera streams using AI vision. version: 1.0.4 author: kami-smarthome tags: - smart-home - kami - home-assistant - smarthome - detect - object-detection - yolo - package-detection - parcel-detection - iot - camera - rtsp - onnx - edge-ai - delivery - monitoring - notification triggers: - smart home - kami - home assistant - detect - detect packages - detect parcels - kami package - kami smart home - home assistant package - smart home delivery - check doorstep - delivery notification - check for deliveries - package detection - is there a package - monitor packages - check camera for packages - any deliveries at the door - parcel alert metadata: openclaw: requires: bins: - python3.10 hardware: cpu: "2+ cores (x86_64 / ARM64)" memory: "2 GB+" storage: "2 GB+" gpu: "optional (speeds up ONNX inference)" network: - "RTSP camera access (LAN)" - "Internet (KamiClaw API)" devices: - "RTSP IP camera" emoji: "📦" --- # Kami Package Detection > Get notified the moment a package arrives at your door. Monitor your camera feed for packages, parcels, backpacks, handbags, and suitcases. When detected, returns the object class and bounding box as JSON — ready for automation. ### Features - 📦 Package & parcel detection - 🧳 Suitcase / backpack / handbag recognition - 🏠 Doorstep & reception monitoring - ⏱ Configurable detection duration - 🔔 JSON output for easy integration ### Scenarios - Doorstep delivery waiting - Office reception package management - Warehouse cargo monitoring - Temporary item watch ## Installation ```bash bash setup.sh ``` Creates `.venv/` and installs `onnxruntime`, `opencv-python-headless`, `numpy`. Idempotent. ## Prerequisites - `python3` and `python3-venv` installed - `yolov8s-worldv2.onnx` model file in the skill directory - RTSP camera(s) online and reachable ### Model The `yolov8s-worldv2.onnx` model file is included in the skill package. If missing, re-download this skill from ClawHub: ```bash clawhub install kami-package-detection ``` Alternatively, download the YOLOv8s-World v2 `.pt` model from [Ultralytics YOLO-World](https://docs.ultralytics.com/models/yolo-world/) and export it to ONNX yourself. Make sure the class names used during export match the `--class_names` parameter: ```bash pip install ultralytics yolo export model=yolov8s-worldv2.pt format=onnx imgsz=320 cp yolov8s-worldv2.onnx /path/to/kami-package-detection/ ``` ## Parameter Confirmation Parameters can be supplied via either `config.json` (recommended for repeated use) or command-line flags. Command-line flags override `config.json`, which overrides built-in defaults. | Parameter | `config.json` field | Default | Description | |-----------|---------------------|---------|-------------| | `--device` | *(selects from cameras array)* | first camera | Target camera DEVICE_ID | | `--rtsp_url` | `cameras[].rtsp_url` | — | RTSP camera URL (overrides camera selection) | | `--conf_threshold` | `conf_threshold` | `0.25` | Confidence threshold (0.0–1.0) | | `--class_names` | *(not in config.json)* | `parcel package "delivery box" person "Cardboard box" "Packaging Box" backpack handbag suitcase` | Classes to detect (CLI only) | | `--run_time` | `run_time` | `60` | Max seconds; `0` = unlimited | | `--start-detect` | — | — | Start background detection (all cameras or `--device`) | | `--stop-detect` | — | — | Stop background detection (all cameras or `--device`) | | `--status` | — | — | Check detection process status | | `--list-devices` | — | — | List all configured cameras and exit | ### Multi-Camera Configuration `config.json` supports a `cameras` array for multiple cameras: ```json { "cameras": [ { "rtsp_url": "rtsp://192.168.1.100/stream", "device_id": "CAM-FRONT" }, { "rtsp_url": "rtsp://192.168.1.101/stream", "device_id": "CAM-BACK", "conf_threshold": 0.3, "run_time": 120 } ], "conf_threshold": 0.25, "run_time": 60 } ``` - `device_id` must be unique across all cameras - Per-camera `conf_threshold` and `run_time` override global values - Without `--device`, all cameras are started/stopped together - Each camera runs as an independent background process - Legacy single-camera config (flat `rtsp_url` at top level) is still supported > When installed as part of `kami-smarthome-suite`, `rtsp_url`, `conf_threshold` and `run_time` are auto-distributed into `config.json` from the central `kami_config.json`. `class_names` is intentionally NOT distributed and stays a per-skill default. **Ask the user: do any parameters need to be changed?** ## Usage ### Start Detection (Background) ```bash # Start all cameras .venv/bin/python yolo_world_onnx.py --start-detect # Start a specific camera .venv/bin/python yolo_world_onnx.py --start-detect --device CAM-FRONT ``` ### Stop Detection ```bash # Stop all cameras .venv/bin/python yolo_world_onnx.py --stop-detect # Stop a specific camera .venv/bin/python yolo_world_onnx.py --stop-detect --device CAM-FRONT ``` ### Check Status ```bash # Status of all cameras .venv/bin/python yolo_world_onnx.py --status # Status of a specific camera .venv/bin/python yolo_world_onnx.py --status --device CAM-FRONT ``` ### Single-Run Mode (Foreground) ```bash # Run detection on a specific camera (foreground, exits on detection or timeout) .venv/bin/python yolo_world_onnx.py --device CAM-FRONT # Override via CLI .venv/bin/python yolo_world_onnx.py \ --rtsp_url rtsp://your-camera-address \ --run_time 60 # List configured cameras .venv/bin/python yolo_world_onnx.py --list-devices ``` ## Output (stdout JSON) ```json { "detections": [ { "class_name": "parcel", "bbox": {"x1": 100, "y1": 200, "x2": 300, "y2": 400} } ] } ``` | Field | Type | Description | |-------|------|-------------| | `class_name` | string | Detected object class | | `bbox.x1, y1, x2, y2` | int | Bounding box coordinates | ## Exit Codes | Code | Meaning | |------|---------| | `0` | Target detected, JSON output written | | `1` | Error (model missing, RTSP failure, runtime exception) | | `2` | Timeout, no target detected within `--run_time` | ## Troubleshooting - `bash: .venv/bin/python: No such file or directory` → Run `bash setup.sh` - `Model file not found` → Place `yolov8s-worldv2.onnx` in the skill directory - `Cannot open video` → Check camera is online and `--rtsp_url` is correct ## Privacy Notice This skill processes camera video stream frames for object detection. Please review the following privacy information before use: ### Pure Local Inference - Detection runs entirely on-device via the YOLOv8-World ONNX model — **no API key, no cloud calls, no external network traffic** - The only outbound traffic is the RTSP pull from your own camera (LAN) ### Local Data Storage - Frames are held in memory only and discarded after each inference — **nothing is persisted to disk** - The skill emits a single JSON object to **stdout**; if you need history, the caller is responsible for storing it ### User Control - Camera URL is supplied by the user; this skill will not auto-discover or connect to cameras - You can stop the skill at any time — there is no background daemon, no cache, and no residual data - Removing the skill directory wipes everything (model file + venv); nothing else is touched on the host > For more details on our privacy policy, visit: https://kamiclaw-skill.kamihome.com/privacy

path: README.md
content: # 📦 Kami Package Detection

> A free skill by **Kami SmartHome** — get notified the moment a package arrives at your door.

Monitor your RTSP camera feed for packages, parcels, backpacks, handbags, and suitcases using YOLOv8-World ONNX inference. When detected, outputs the object class and bounding box as structured JSON to stdout.

### Features

- 📦 Package & parcel detection
- 🧳 Suitcase / backpack / handbag recognition
- 🏠 Doorstep & reception monitoring
- ⏱ Configurable detection duration
- 🔔 JSON output for easy integration
- 🖥 CPU-only inference via `onnxruntime` — no GPU required
- 🐍 Isolated `.venv` — zero impact on system Python

### Scenarios

- Doorstep delivery waiting
- Office reception package management
- Warehouse cargo monitoring
- Temporary item watch

## Quick Start

```bash
# 1. Run setup (one-time)
bash setup.sh

# 2. Place your ONNX model file
cp /path/to/yolov8s-worldv2.onnx .

# 3a. Foreground single-run (exits on first detection or timeout)
.venv/bin/python yolo_world_onnx.py \
  --rtsp_url rtsp://192.168.1.100/live/camera01 \
  --conf_threshold 0.25 \
  --class_names parcel package "delivery box" person "Cardboard box" "Packaging Box" backpack handbag suitcase \
  --run_time 120

# 3b. Background daemon for one or more cameras (uses config.json)
.venv/bin/python yolo_world_onnx.py --start-detect
.venv/bin/python yolo_world_onnx.py --status
.venv/bin/python yolo_world_onnx.py --stop-detect
```

## Installation

```bash
bash setup.sh
```

The setup script automatically:
1. Detects system Python (`python3` or `python`), exits with error if not found
2. Creates an isolated virtual environment at `.venv/`
3. Installs Python dependencies from `requirements.txt`

> Idempotent — safe to run multiple times. Won't reinstall existing dependencies.

### Requirements

| Dependency | Version | Purpose |
|------------|---------|---------|
| `onnxruntime` | latest | ONNX model inference engine |
| `opencv-python-headless` | latest | Video capture and image processing |
| `numpy` | latest | Array operations |

### System Prerequisites

- **Python 3.8+** and **python3-venv** (`sudo apt install python3 python3-venv`)
- **ONNX model file**: `yolov8s-worldv2.onnx` in the skill directory
- **RTSP camera**: online and network-reachable

## Usage

### Basic Detection

Parameters can be supplied via either `config.json` (recommended) or command-line flags. CLI flags > `config.json` > built-in defaults.

```bash
# Option 1: Edit config.json once, then just run
.venv/bin/python yolo_world_onnx.py

# Option 2: Override via CLI
.venv/bin/python yolo_world_onnx.py \
  --rtsp_url rtsp://127.0.0.1/live/TNPUSAQ-757597-DRFMY \
  --conf_threshold 0.25 \
  --class_names parcel package "delivery box" person "Cardboard box" "Packaging Box" backpack handbag suitcase \
  --run_time 60
```

### Multi-Camera Configuration

`config.json` supports a `cameras` array so multiple RTSP feeds can run as independent background processes:

```json
{
  "cameras": [
    {
      "rtsp_url": "rtsp://192.168.1.100/stream",
      "device_id": "CAM-FRONT"
    },
    {
      "rtsp_url": "rtsp://192.168.1.101/stream",
      "device_id": "CAM-BACK",
      "conf_threshold": 0.3,
      "run_time": 120
    }
  ],
  "conf_threshold": 0.25,
  "run_time": 60
}
```

- `device_id` must be unique across all cameras
- Per-camera `conf_threshold` and `run_time` override global values
- Without `--device`, all cameras start/stop together
- Legacy single-camera config (flat `rtsp_url` at top level) is still supported

### Daemon Mode (Multi-Camera)

```bash
# Start all cameras
.venv/bin/python yolo_world_onnx.py --start-detect

# Start a specific camera
.venv/bin/python yolo_world_onnx.py --start-detect --device CAM-FRONT

# Status (all cameras or a specific one)
.venv/bin/python yolo_world_onnx.py --status
.venv/bin/python yolo_world_onnx.py --status --device CAM-FRONT

# Stop (all cameras or a specific one)
.venv/bin/python yolo_world_onnx.py --stop-detect
.venv/bin/python yolo_world_onnx.py --stop-detect --device CAM-FRONT

# List all configured cameras
.venv/bin/python yolo_world_onnx.py --list-devices
```

### Parameters

| Parameter | `config.json` field | Type | Required | Default | Description |
|-----------|---------------------|------|----------|---------|-------------|
| `--rtsp_url` | `rtsp_url` / `cameras[].rtsp_url` | string | No | `rtsp://127.0.0.1/live/TNPUSAQ-757597-DRFMY` | RTSP camera stream URL (overrides camera selection when used directly) |
| `--device` | *(selects from `cameras` array)* | string | No | first camera | Target camera `device_id` (foreground or daemon mode) |
| `--conf_threshold` | `conf_threshold` | float | No | `0.25` | Detection confidence threshold (0.0–1.0) |
| `--class_names` | *(not in config.json)* | string[] | No | `parcel package "delivery box" person "Cardboard box" "Packaging Box" backpack handbag suitcase` | Space-separated class names to detect (CLI only) |
| `--run_time` | `run_time` | int | No | `60` | Max run time in seconds. `0` = unlimited |
| `--start-detect` | — | flag | No | — | Start background detection (all cameras, or one with `--device`) |
| `--stop-detect` | — | flag | No | — | Stop background detection (all cameras, or one with `--device`) |
| `--status` | — | flag | No | — | Check detection process status |
| `--list-devices` | — | flag | No | — | List all configured cameras and exit |

> When installed as part of `kami-smarthome-suite`, `cameras[]`, `conf_threshold` and `run_time` are auto-distributed into `config.json` from the central `kami_config.json`. `class_names` is a per-skill default and not distributed.

## Output Format

When a non-person target is detected, the skill outputs JSON to **stdout** and exits with code `0`:

```json
{
  "detections": [
    {
      "class_name": "parcel",
      "bbox": {"x1": 100, "y1": 200, "x2": 300, "y2": 400}
    }
  ]
}
```

### Field Reference

| Field | Type | Description |
|-------|------|-------------|
| `detections` | array | List of detected objects |
| `detections[].class_name` | string | Detected object class |
| `detections[].bbox.x1` | int | Bounding box left x |
| `detections[].bbox.y1` | int | Bounding box top y |
| `detections[].bbox.x2` | int | Bounding box right x |
| `detections[].bbox.y2` | int | Bounding box bottom y |

## Exit Codes

| Code | Meaning |
|------|---------|
| `0` | Target detected and JSON output written |
| `1` | Model file missing, RTSP connection failure, or runtime error |
| `2` | Run time exceeded, no target detected (timeout) |

> Exit code `0` = detection success, `2` = timeout with no detection, `1` = error.

## Architecture

```
RTSP Camera → [yolo_world_onnx.py] → JSON stdout
                    │
                    ├── letterbox (preprocessing)
                    ├── ONNX inference (YOLOv8-World)
                    ├── parse_output + NMS (post-processing)
                    └── format_detection_result (JSON output)
```

The skill follows a single-pass detection model:
1. Read frames from RTSP stream
2. Preprocess each frame (letterbox resize to 320×320)
3. Run ONNX inference
4. Parse detections, apply NMS
5. On first non-person detection → output JSON → exit

## Error Handling

| Scenario | Behavior | Exit Code |
|----------|----------|-----------|
| Model file (`yolov8s-worldv2.onnx`) not found | Log error, exit immediately | `1` |
| RTSP stream cannot connect | Log error, exit immediately | `1` |
| Model load failure (corrupt ONNX) | Log error, exit immediately | `1` |
| Run time exceeded | Log timeout info, exit | `2` |
| Video stream ends (no more frames) | Log warning, exit | `2` |

## Troubleshooting

### Virtual environment not found
```
bash: .venv/bin/python: No such file or directory
```
**Fix**: Run `bash setup.sh` to initialize the environment.

### Model file missing
```
Model file not found: .../yolov8s-worldv2.onnx
```
**Fix**: Download or copy `yolov8s-worldv2.onnx` into the skill directory.

### RTSP connection failure
```
Cannot open video: rtsp://...
```
**Fix**: Verify the camera is online, check the `--rtsp_url` value, and confirm network connectivity.

### Low detection rate
- Try lowering `--conf_threshold` (e.g., `0.15`)
- Ensure the target object class is included in `--class_names`
- Check camera angle and lighting conditions

## File Structure

```
kami-package-detection/
├── .gitignore
├── requirements.txt          # onnxruntime, opencv-python-headless, numpy
├── setup.sh                  # Environment setup with Python auto-detection
├── SKILL.md                  # AI agent instructions + metadata
├── README.md                 # This file
├── yolo_world_onnx.py        # Main detection script
├── yolov8s-worldv2.onnx      # ONNX model file (not included, user-provided)
└── tests/
    └── test_parcel_detection.py
```

## Privacy Notice

This skill processes camera video stream frames for object detection. Please review the following privacy information before use:

### Pure Local Inference

- Detection runs entirely on-device via the YOLOv8-World ONNX model — **no API key, no cloud calls, no external network traffic**
- The only outbound traffic is the RTSP pull from your own camera (LAN)

### Local Data Storage

- Frames are held in memory only and discarded after each inference — **nothing is persisted to disk**
- The skill emits a single JSON object to **stdout**; if you need history, the caller is responsible for storing it

### User Control

- Camera URL is supplied by the user; this skill will not auto-discover or connect to cameras
- You can stop the skill at any time — there is no background daemon, no cache, and no residual data
- Removing the skill directory wipes everything (model file + venv); nothing else is touched on the host

> For more details on our privacy policy, visit: https://kamiclaw-skill.kamihome.com/privacy

## License

MIT-0


path: config.json
content: {
  "cameras": [
    {
      "rtsp_url": "",
      "device_id": "CAM-001"
    }
  ],
  "conf_threshold": 0.25,
  "run_time": 60
}


path: requirements.txt
content: onnxruntime
opencv-python-headless
numpy


path: setup.sh
content: #!/bin/bash
set -e

SKILL_DIR="$(dirname "$(realpath "$0")")"
VENV_DIR="$SKILL_DIR/.venv"

# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'

print_success() { echo -e "${GREEN}[✓] $1${NC}"; }
print_warn()    { echo -e "${YELLOW}[!] $1${NC}"; }
print_error()   { echo -e "${RED}[✗] $1${NC}"; }
print_info()    { echo -e "${BLUE}[i] $1${NC}"; }

# Required Python version (resolved by check_python310)
REQUIRED_PYTHON=""

# ============================================================
# Step 1: Check / install Python 3.10
#   Strategy aligned with kami-smarthome-suite:
#   - Debian/Ubuntu : conda > pyenv > system python3.10 (avoid sudo for venv)
#   - Other systems : system python3.10 > pyenv > conda
#   - Auto-install fallback: conda > pyenv (no sudo)
# ============================================================

# Detect if system is Debian/Ubuntu based
is_debian_based() {
    [ -f /etc/debian_version ] || [ -f /etc/ubuntu-version ]
}

# Check if a Python has venv module
has_venv_module() {
    local python_bin="$1"
    "$python_bin" -m venv --help >/dev/null 2>&1
}

# Install pyenv + Python 3.10.14 (no sudo for pyenv itself; build deps may need sudo)
install_pyenv() {
    print_info "Installing pyenv (no sudo required)..."

    if [ -d "$HOME/.pyenv" ]; then
        print_success "pyenv already installed at $HOME/.pyenv"
    else
        git clone --depth 1 https://github.com/pyenv/pyenv.git "$HOME/.pyenv" 2>/dev/null || {
            print_error "Failed to clone pyenv. Please check network or install manually:"
            echo "  git clone --depth 1 https://github.com/pyenv/pyenv.git \$HOME/.pyenv"
            exit 2
        }
        print_success "pyenv installed to $HOME/.pyenv"
    fi

    if [[ ":$PATH:" != *":$HOME/.pyenv/bin:"* ]]; then
        export PYENV_ROOT="$HOME/.pyenv"
        export PATH="$PYENV_ROOT/bin:$PATH"
        eval "$(pyenv init --path 2>/dev/null)" || true
        eval "$(pyenv virtualenv-init - 2>/dev/null)" || true
    fi

    print_info "Installing Python 3.10 via pyenv (this may take 5-10 minutes)..."
    print_info "Note: If build fails, you may need build dependencies:"
    echo "  sudo apt install make build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev libsqlite3-dev libncursesw5-dev xz-utils tk-dev libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev"
    echo ""

    if ! pyenv install 3.10.14; then
        print_error "pyenv install failed (likely missing build dependencies)."
        echo ""
        read -p "Install build dependencies with sudo? [y/N] " confirm
        if [[ "$confirm" =~ ^[Yy]$ ]]; then
            sudo apt update && sudo apt install -y \
                make build-essential libssl-dev zlib1g-dev \
                libbz2-dev libreadline-dev libsqlite3-dev \
                libncursesw5-dev xz-utils tk-dev libxml2-dev \
                libxmlsec1-dev libffi-dev liblzma-dev
            pyenv install 3.10.14 || {
                print_error "Still failed. Please check errors above."
                exit 2
            }
        else
            print_error "Cannot proceed without Python 3.10."
            exit 2
        fi
    fi

    pyenv global 3.10.14

    local pyenv_python
    pyenv_python=$(pyenv which python3.10 2>/dev/null || echo "")
    if [ -n "$pyenv_python" ]; then
        REQUIRED_PYTHON="$pyenv_python"
        print_success "pyenv Python 3.10 ready: $($REQUIRED_PYTHON --version) → $REQUIRED_PYTHON"
        return 0
    else
        print_error "pyenv Python 3.10 setup failed."
        exit 2
    fi
}

# Try to source conda from common locations (idempotent)
ensure_conda_sourced() {
    if conda info --envs >/dev/null 2>&1; then
        return 0
    fi
    local init_path
    for init_path in "$HOME/miniconda3/etc/profile.d/conda.sh" \
                     "$HOME/anaconda3/etc/profile.d/conda.sh" \
                     "$HOME/mambaforge/etc/profile.d/conda.sh" \
                     "$HOME/micromamba/etc/profile.d/conda.sh"; do
        if [ -f "$init_path" ]; then
            # shellcheck disable=SC1090
            source "$init_path" 2>/dev/null || true
            break
        fi
    done
}

# Scan all conda envs for Python 3.10 with venv module; sets REQUIRED_PYTHON on success
find_conda_python310() {
    ensure_conda_sourced
    local envs_output
    envs_output=$(conda info --envs 2>/dev/null || echo "")
    [ -z "$envs_output" ] && return 1

    local envs
    envs=$(echo "$envs_output" | grep -v '^#' | awk '{print $1}' | grep -v '^$')
    local env env_python env_ver
    for env in $envs; do
        env_python=$(conda run -n "$env" which python 2>/dev/null || echo "")
        [ -z "$env_python" ] && continue
        env_ver=$(conda run -n "$env" python --version 2>&1 | cut -d' ' -f2)
        if [[ "$env_ver" == 3.10* ]] && has_venv_module "$env_python"; then
            REQUIRED_PYTHON="$env_python"
            print_success "conda Python 3.10 found (env: $env): $env_ver → $REQUIRED_PYTHON"
            return 0
        fi
    done
    return 1
}

# Try system python3.10
find_system_python310() {
    if command -v python3.10 &>/dev/null; then
        local sys_python
        sys_python="$(command -v python3.10)"
        if has_venv_module "$sys_python"; then
            REQUIRED_PYTHON="$sys_python"
            print_success "System Python 3.10 found: $($REQUIRED_PYTHON --version) → $REQUIRED_PYTHON"
            return 0
        else
            print_warn "System Python 3.10 found but venv module missing"
        fi
    fi
    return 1
}

# Try pyenv python3.10
find_pyenv_python310() {
    if command -v pyenv &>/dev/null; then
        local pyenv_310
        pyenv_310=$(pyenv which python3.10 2>/dev/null || echo "")
        if [ -n "$pyenv_310" ] && [ -f "$pyenv_310" ] && has_venv_module "$pyenv_310"; then
            REQUIRED_PYTHON="$pyenv_310"
            print_success "pyenv Python 3.10 found: $($REQUIRED_PYTHON --version) → $REQUIRED_PYTHON"
            return 0
        fi
    fi
    return 1
}

# Auto-install: conda first, then pyenv
auto_install_python310() {
    print_error "Python 3.10 not found."
    echo ""

    if command -v conda &>/dev/null; then
        print_info "conda found. Creating Python 3.10 environment (km310)..."
        conda create -n km310 python=3.10 -y -q
        local conda_python
        conda_python=$(conda run -n km310 which python 2>/dev/null || echo "")
        if [ -n "$conda_python" ] && has_venv_module "$conda_python"; then
            REQUIRED_PYTHON="$conda_python"
            print_success "conda Python 3.10 ready: $($REQUIRED_PYTHON --version) → $REQUIRED_PYTHON"
            return 0
        else
            print_warn "conda Python 3.10 setup failed, falling back to pyenv..."
        fi
    fi

    install_pyenv
}

check_python310() {
    if is_debian_based; then
        print_info "Debian/Ubuntu detected: prioritizing conda/pyenv (avoids sudo for python3.10-venv)"
        find_conda_python310 && return 0
        find_pyenv_python310 && return 0
        find_system_python310 && return 0
    else
        find_system_python310 && return 0
        find_pyenv_python310 && return 0
        find_conda_python310 && return 0
    fi
    auto_install_python310
}

check_python310

# ============================================================
# Step 2: Create virtual environment with Python 3.10
# ============================================================

if [ ! -d "$VENV_DIR" ]; then
    print_info "Creating virtual environment with $REQUIRED_PYTHON..."
    "$REQUIRED_PYTHON" -m venv "$VENV_DIR"
fi

# ============================================================
# Step 3: Install dependencies
# ============================================================

"$VENV_DIR/bin/pip" install -q -r "$SKILL_DIR/requirements.txt"

print_success "Setup complete."


path: yolo_world_onnx.py
content: import cv2
import numpy as np
import onnxruntime as ort
import time
import os
import sys
import json
import logging
import argparse
from datetime import datetime, timedelta, timezone
import re

DEFAULT_DATE = "2000-01-01"  # Default date for time-only strings


def parse_time(time_str: str) -> datetime:
    """
    Parse a time string and return a timezone-aware datetime object.
    Supports three formats:
      1) ISO UTC: "2024-03-25T06:00:00Z"
      2) CST local: "14:00:00 2024-03-25"  (UTC+8)
      3) Time only: "14:00:00"             (default date + CST)
    """
    # Format 1: ISO UTC ending with Z
    if time_str.endswith('Z') and 'T' in time_str:
        dt = datetime.fromisoformat(time_str.replace('Z', '+00:00'))
        if dt.tzinfo is None:
            dt = dt.replace(tzinfo=timezone.utc)
        return dt

    # Format 2: CST local "HH:MM:SS YYYY-MM-DD"
    pattern_full = r'^(\d{2}):(\d{2}):(\d{2}) (\d{4})-(\d{2})-(\d{2})$'
    match = re.match(pattern_full, time_str)
    if match:
        h, m, s, y, mo, d = map(int, match.groups())
        cst_tz = timezone(timedelta(hours=8))
        return datetime(y, mo, d, h, m, s, tzinfo=cst_tz)

    # Format 3: Time only "HH:MM:SS" (default date + CST)
    pattern_time = r'^(\d{2}):(\d{2}):(\d{2})$'
    match = re.match(pattern_time, time_str)
    if match:
        h, m, s = map(int, match.groups())
        cst_tz = timezone(timedelta(hours=8))
        y, mo, d = map(int, DEFAULT_DATE.split('-'))
        return datetime(y, mo, d, h, m, s, tzinfo=cst_tz)

    raise ValueError(f"Unsupported time format: {time_str}")


# ==================== Logging Configuration ====================
script_dir = os.path.dirname(os.path.abspath(__file__))
log_file = os.path.join(script_dir, "yolo_world_onnx_log.log")
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
    handlers=[
        logging.FileHandler(log_file, encoding='utf-8'),
        logging.StreamHandler()
    ]
)
logger = logging.getLogger(__name__)


# ------------------ Helper Functions ------------------
def letterbox(img, new_shape=320, color=(114, 114, 114)):
    shape = img.shape[:2]
    if isinstance(new_shape, int):
        new_shape = (new_shape, new_shape)
    r = min(new_shape[0] / shape[0], new_shape[1] / shape[1])
    new_unpad = int(round(shape[1] * r)), int(round(shape[0] * r))
    dw, dh = new_shape[1] - new_unpad[0], new_shape[0] - new_unpad[1]
    dw /= 2
    dh /= 2
    if shape[::-1] != new_unpad:
        img = cv2.resize(img, new_unpad, interpolation=cv2.INTER_LINEAR)
    top, bottom = int(round(dh - 0.1)), int(round(dh + 0.1))
    left, right = int(round(dw - 0.1)), int(round(dw + 0.1))
    img = cv2.copyMakeBorder(img, top, bottom, left, right, cv2.BORDER_CONSTANT, value=color)
    return img, r, (dw, dh)


def parse_output(output, conf_threshold=0.25, class_names=None):
    if len(output.shape) == 3:
        output = output.transpose(0, 2, 1)[0]
    else:
        output = output[0]
    boxes = []
    scores = []
    class_ids = []
    num_classes = len(class_names) if class_names is not None else output.shape[1] - 4
    for pred in output:
        bbox = pred[:4]
        cls_scores = pred[4:4+num_classes]
        max_score = np.max(cls_scores)
        if max_score > conf_threshold:
            class_id = np.argmax(cls_scores)
            boxes.append(bbox)
            scores.append(max_score)
            class_ids.append(class_id)
    return np.array(boxes), np.array(scores), np.array(class_ids)


def xywh2xyxy(x):
    y = np.copy(x)
    y[..., 0] = x[..., 0] - x[..., 2] / 2
    y[..., 1] = x[..., 1] - x[..., 3] / 2
    y[..., 2] = x[..., 0] + x[..., 2] / 2
    y[..., 3] = x[..., 1] + x[..., 3] / 2
    return y


def scale_boxes(boxes, orig_shape, ratio, pad):
    if len(boxes) == 0:
        return boxes
    boxes = xywh2xyxy(boxes)
    boxes[:, [0, 2]] -= pad[0]
    boxes[:, [1, 3]] -= pad[1]
    boxes /= ratio
    boxes[:, [0, 2]] = np.clip(boxes[:, [0, 2]], 0, orig_shape[1])
    boxes[:, [1, 3]] = np.clip(boxes[:, [1, 3]], 0, orig_shape[0])
    return boxes.astype(int)


def nms(boxes, scores, iou_threshold):
    if len(boxes) == 0:
        return []
    indices = cv2.dnn.NMSBoxes(boxes.tolist(), scores.tolist(), 0.0, iou_threshold)
    if len(indices) > 0:
        return indices.flatten()
    return []


def get_color(class_id):
    colors = [
        (255, 0, 0),    # blue
        (0, 255, 0),    # green
        (0, 0, 255),    # red
        (255, 255, 0),  # cyan
        (255, 0, 255),  # magenta
        (0, 255, 255)   # yellow
    ]
    return colors[class_id % len(colors)]


def format_detection_result(class_name: str, bbox: list) -> dict:
    """Format a single detection result as a dictionary."""
    return {
        "class_name": class_name,
        "bbox": {"x1": int(bbox[0]), "y1": int(bbox[1]), "x2": int(bbox[2]), "y2": int(bbox[3])}
    }


# ------------------ Configuration ------------------
DEFAULT_CLASS_NAMES = [
    "parcel", "package", "delivery box", "person",
    "Cardboard box", "Packaging Box", "backpack", "handbag", "suitcase",
]


def load_config():
    """Load config.json from the skill directory.

    Returns an empty dict if the file is missing or invalid. Empty / null
    field values are skipped so that argparse can fall back to its built-in
    defaults. This makes the script work both standalone (no config.json)
    and as part of the kami-smarthome-suite (suite distributes values into
    config.json via configure.py).

    Supports both legacy format (flat rtsp_url) and new multi-camera format
    (cameras array with device_id + rtsp_url per entry).
    """
    cfg_path = os.path.join(script_dir, "config.json")
    if not os.path.isfile(cfg_path):
        return {}
    try:
        with open(cfg_path, "r", encoding="utf-8") as f:
            data = json.load(f) or {}
    except Exception as exc:
        logger.warning(f"Failed to read config.json, falling back to defaults: {exc}")
        return {}
    # Drop empty values so argparse defaults take over
    return {k: v for k, v in data.items() if v not in (None, "", [], {})}


def _resolve_camera(cfg: dict, device_id: str = None) -> dict:
    """Resolve camera configuration from config dict.

    Supports:
    - New format: cfg["cameras"] array, select by device_id or first entry
    - Legacy format: cfg["rtsp_url"] at top level

    Returns dict with at least {"rtsp_url": ..., "device_id": ...}.
    Per-camera overrides (conf_threshold, run_time) are merged if present.
    """
    cameras = cfg.get("cameras")
    if cameras and isinstance(cameras, list):
        if device_id:
            for cam in cameras:
                if cam.get("device_id") == device_id:
                    return cam
            # device_id not found — print error and exit
            available = [c.get("device_id", "?") for c in cameras]
            print(f"Error: DEVICE_ID '{device_id}' not found in config. "
                  f"Available: {available}", file=sys.stderr)
            sys.exit(1)
        else:
            return cameras[0]
    # Legacy format fallback
    return {"rtsp_url": cfg.get("rtsp_url", ""), "device_id": "CAM-001"}


def parse_args():
    cfg = load_config()
    parser = argparse.ArgumentParser(description='YOLO-World ONNX Object Detection')
    parser.add_argument('--device', type=str, default=None,
                        help='Target camera DEVICE_ID (selects from cameras array)')
    parser.add_argument('--rtsp_url', type=str, default=None,
                        help='RTSP camera URL (overrides camera selection)')
    parser.add_argument('--conf_threshold', type=float, default=None,
                        help='Confidence threshold')
    parser.add_argument('--class_names', type=str, nargs='+',
                        default=DEFAULT_CLASS_NAMES,
                        help='List of class names to detect (not distributed by suite)')
    parser.add_argument('--run_time', type=int, default=None,
                        help='Max run time in seconds, 0 for unlimited')
    parser.add_argument('--list-devices', action='store_true',
                        help='List all configured cameras and exit')
    parser.add_argument('--start-detect', action='store_true',
                        help='Start background detection (all cameras or --device)')
    parser.add_argument('--stop-detect', action='store_true',
                        help='Stop background detection (all cameras or --device)')
    parser.add_argument('--status', action='store_true',
                        help='Check detection process status')
    args = parser.parse_args()

    # Handle --list-devices
    if args.list_devices:
        cameras = cfg.get("cameras", [])
        if cameras:
            result = [{"device_id": c.get("device_id", "?"),
                       "rtsp_url": c.get("rtsp_url", "")} for c in cameras]
        else:
            result = [{"device_id": "CAM-001",
                       "rtsp_url": cfg.get("rtsp_url", "")}]
        print(json.dumps(result, ensure_ascii=False, indent=2))
        sys.exit(0)

    # For daemon management commands, attach cfg and return early
    if args.start_detect or args.stop_detect or args.status:
        args._cfg = cfg
        return args

    # Resolve camera
    cam = _resolve_camera(cfg, args.device)

    # Determine final rtsp_url: CLI > camera entry > global config > built-in default
    if args.rtsp_url is None:
        rtsp_url = cam.get("rtsp_url") or cfg.get("rtsp_url") or ""
    else:
        rtsp_url = args.rtsp_url
    args.rtsp_url = rtsp_url

    # Determine conf_threshold: CLI > camera entry > global config > default
    if args.conf_threshold is None:
        args.conf_threshold = cam.get("conf_threshold") or cfg.get("conf_threshold", 0.25)

    # Determine run_time: CLI > camera entry > global config > default
    if args.run_time is None:
        args.run_time = cam.get("run_time") or cfg.get("run_time", 60)

    # Attach device_id to args for downstream use
    args.device_id = cam.get("device_id", "CAM-001")

    return args


# ==================== PID / Daemon Management ====================

import signal
import subprocess as _subprocess
from pathlib import Path


def _get_pid_dir(device_id: str) -> str:
    """PID directory for a camera: {script_dir}/run/{device_id}/"""
    d = os.path.join(script_dir, "run", device_id)
    Path(d).mkdir(parents=True, exist_ok=True)
    return d


def _get_pid_file(device_id: str) -> str:
    return os.path.join(_get_pid_dir(device_id), "detect.pid")


def _read_pid(pid_file: str):
    if not os.path.exists(pid_file):
        return None
    try:
        with open(pid_file, "r") as f:
            return int(f.read().strip())
    except (ValueError, OSError):
        return None


def _is_pid_alive(pid: int) -> bool:
    try:
        os.kill(pid, 0)
        return True
    except OSError:
        return False


def _start_daemon(cfg: dict, device_id: str, config_path: str) -> dict:
    """Start detection for a specific camera as a background process."""
    pid_file = _get_pid_file(device_id)
    existing_pid = _read_pid(pid_file)

    if existing_pid and _is_pid_alive(existing_pid):
        return {"status": "already_running", "pid": existing_pid,
                "device_id": device_id,
                "message": f"Detection already running (PID={existing_pid})"}

    # Prefer venv python
    venv_python = os.path.join(script_dir, ".venv", "bin", "python")
    if not os.path.exists(venv_python):
        venv_python = sys.executable

    cmd = [venv_python, os.path.abspath(__file__), "--device", device_id]

    proc = _subprocess.Popen(
        cmd, stdout=_subprocess.DEVNULL, stderr=_subprocess.DEVNULL,
        start_new_session=True,
    )

    with open(pid_file, "w") as f:
        f.write(str(proc.pid))

    return {"status": "started", "pid": proc.pid, "device_id": device_id,
            "message": f"Detection started (PID={proc.pid})"}


def _stop_daemon(device_id: str) -> dict:
    """Stop detection for a specific camera."""
    pid_file = _get_pid_file(device_id)
    pid = _read_pid(pid_file)

    if pid is None:
        return {"status": "not_running", "device_id": device_id,
                "message": "No running detection process found"}

    if not _is_pid_alive(pid):
        os.remove(pid_file)
        return {"status": "not_running", "device_id": device_id,
                "message": f"Process (PID={pid}) no longer exists, PID file cleaned up"}

    os.kill(pid, signal.SIGTERM)
    for _ in range(20):
        if not _is_pid_alive(pid):
            break
        time.sleep(0.5)

    if _is_pid_alive(pid):
        os.kill(pid, signal.SIGKILL)
        time.sleep(0.5)

    if os.path.exists(pid_file):
        os.remove(pid_file)

    return {"status": "stopped", "pid": pid, "device_id": device_id,
            "message": f"Detection stopped (PID={pid})"}


def _get_status(device_id: str) -> dict:
    """Get detection process status for a camera."""
    pid_file = _get_pid_file(device_id)
    pid = _read_pid(pid_file)

    if pid is None:
        return {"status": "not_running", "device_id": device_id,
                "message": "Detection not running"}

    if _is_pid_alive(pid):
        return {"status": "running", "pid": pid, "device_id": device_id,
                "message": f"Detection running (PID={pid})"}
    else:
        os.remove(pid_file)
        return {"status": "not_running", "device_id": device_id,
                "message": f"Process (PID={pid}) no longer exists, PID file cleaned up"}


def _get_all_cameras(cfg: dict) -> list:
    """Get list of camera dicts from config."""
    cameras = cfg.get("cameras")
    if cameras and isinstance(cameras, list):
        return cameras
    return [{"rtsp_url": cfg.get("rtsp_url", ""), "device_id": "CAM-001"}]


def main():
    args = parse_args()

    # ---- Daemon management commands ----
    if args.start_detect:
        cfg = args._cfg
        cameras = _get_all_cameras(cfg)
        if args.device:
            # Validate device exists
            _resolve_camera(cfg, args.device)
            result = _start_daemon(cfg, args.device, "")
        else:
            results = []
            for cam in cameras:
                r = _start_daemon(cfg, cam["device_id"], "")
                results.append(r)
            result = {"status": "ok", "cameras": results}
        print(json.dumps(result, ensure_ascii=False))
        return

    if args.stop_detect:
        cfg = args._cfg
        cameras = _get_all_cameras(cfg)
        if args.device:
            _resolve_camera(cfg, args.device)
            result = _stop_daemon(args.device)
        else:
            results = []
            for cam in cameras:
                r = _stop_daemon(cam["device_id"])
                results.append(r)
            result = {"status": "ok", "cameras": results}
        print(json.dumps(result, ensure_ascii=False))
        return

    if args.status:
        cfg = args._cfg
        cameras = _get_all_cameras(cfg)
        if args.device:
            _resolve_camera(cfg, args.device)
            result = _get_status(args.device)
        else:
            results = []
            for cam in cameras:
                r = _get_status(cam["device_id"])
                results.append(r)
            result = {"status": "ok", "cameras": results}
        print(json.dumps(result, ensure_ascii=False))
        return

    # ---- Foreground detection mode ----
    VIDEO_PATH = args.rtsp_url
    ONNX_MODEL = os.path.join(script_dir, "yolov8s-worldv2.onnx")
    CONF_THRESHOLD = args.conf_threshold
    IOU_THRESHOLD = 0.45
    INPUT_SIZE = 320
    CLASS_NAMES = args.class_names
    max_run_time = args.run_time

    # Signal handling for graceful stop
    _stop_requested = [False]

    def _signal_handler(sig, frame):
        logger.info("Received signal %s, stopping", signal.Signals(sig).name)
        _stop_requested[0] = True

    signal.signal(signal.SIGINT, _signal_handler)
    signal.signal(signal.SIGTERM, _signal_handler)

    # Clean up PID file on exit
    def _cleanup_pid():
        pid_file = _get_pid_file(args.device_id)
        if os.path.exists(pid_file):
            try:
                os.remove(pid_file)
            except OSError:
                pass

    import atexit
    atexit.register(_cleanup_pid)

    logger.info("===== Detection started =====")
    logger.info(f"Device ID: {args.device_id}")
    logger.info(f"RTSP URL: {VIDEO_PATH}")
    logger.info(f"Model path: {ONNX_MODEL}")
    logger.info(f"Confidence threshold: {CONF_THRESHOLD}")
    logger.info(f"IOU threshold: {IOU_THRESHOLD}")
    logger.info(f"Input size: {INPUT_SIZE}")
    logger.info(f"Class names: {CLASS_NAMES}")
    if max_run_time > 0:
        logger.info(f"Max run time: {max_run_time} seconds")
    else:
        logger.info("Run time: unlimited")

    # ------------------ Initialize ONNX Runtime ------------------
    if not os.path.exists(ONNX_MODEL):
        logger.error(f"Model file not found: {ONNX_MODEL}")
        sys.exit(1)

    logger.info(f"Loading model: {ONNX_MODEL}")
    try:
        session = ort.InferenceSession(ONNX_MODEL, providers=['CPUExecutionProvider'])
        input_name = session.get_inputs()[0].name
        logger.info("Model loaded successfully")
        logger.info(f"Input shape: {session.get_inputs()[0].shape}")
        logger.info(f"Output shape: {session.get_outputs()[0].shape}")
    except Exception as e:
        logger.error(f"Failed to load model: {e}")
        sys.exit(1)

    # ------------------ Open Video ------------------
    logger.info(f"Opening video source: {VIDEO_PATH}")
    cap = cv2.VideoCapture(VIDEO_PATH)
    if not cap.isOpened():
        logger.error(f"Cannot open video: {VIDEO_PATH}")
        sys.exit(1)

    logger.info("Video opened successfully")
    logger.info(f"Resolution: {int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))}x{int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))}")
    logger.info(f"Total frames: {int(cap.get(cv2.CAP_PROP_FRAME_COUNT))}")
    logger.info("Starting detection")

    # ------------------ Main Loop ------------------
    frame_count = 0
    total_fps = 0
    start_program_time = time.time()

    try:
        while not _stop_requested[0]:
            # Check timeout (only when run_time > 0)
            if max_run_time > 0 and (time.time() - start_program_time) > max_run_time:
                logger.info(f"Run time reached {max_run_time} seconds, exiting")
                break

            ret, frame = cap.read()
            if not ret:
                logger.warning("Cannot read frame, video may have ended")
                break

            frame_count += 1
            start_time = time.time()
            orig_shape = frame.shape[:2]

            # Preprocessing
            img, ratio, pad = letterbox(frame, INPUT_SIZE)
            img = img[:, :, ::-1].transpose(2, 0, 1)
            img = np.ascontiguousarray(img)
            img = img.astype(np.float32) / 255.0
            img = np.expand_dims(img, axis=0)

            # Inference
            outputs = session.run(None, {input_name: img})

            # Parse output
            if len(outputs) > 0:
                boxes, scores, class_ids = parse_output(outputs[0], CONF_THRESHOLD, CLASS_NAMES)

                if len(boxes) > 0:
                    boxes_scaled = scale_boxes(boxes, orig_shape, ratio, pad)
                    keep = nms(boxes_scaled, scores, IOU_THRESHOLD)

                    for i in keep:
                        x1, y1, x2, y2 = boxes_scaled[i]
                        conf = scores[i]
                        cls_id = class_ids[i]

                        # Output JSON and exit when non-person target detected
                        if CLASS_NAMES[cls_id].lower() != "person":
                            logger.info(f"Target detected - frame:{frame_count}, class:{CLASS_NAMES[cls_id]}, confidence:{conf:.2f}")
                            detection = format_detection_result(
                                CLASS_NAMES[cls_id], [x1, y1, x2, y2]
                            )
                            result = {"detections": [detection]}
                            print(json.dumps(result, ensure_ascii=False))
                            cap.release()
                            avg_fps = total_fps / frame_count if frame_count > 0 else 0
                            logger.info(f"Done! Total frames: {frame_count}, Avg FPS: {avg_fps:.2f}")
                            logger.info("===== Program exit =====")
                            sys.exit(0)

            # Calculate FPS
            fps = 1.0 / (time.time() - start_time)
            total_fps += fps

            # Print progress every 30 frames
            if frame_count % 30 == 0:
                avg_fps = total_fps / frame_count
                logger.info(f"Frame {frame_count}, Avg FPS: {avg_fps:.2f}")

    finally:
        cap.release()

    # Normal exit stats (timeout or video ended)
    avg_fps = total_fps / frame_count if frame_count > 0 else 0
    logger.info(f"Done! Total frames: {frame_count}, Avg FPS: {avg_fps:.2f}")
    logger.info("===== Program exit (timeout/no detection) =====")
    sys.exit(2)


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal:  | SkillSpector: suspicious (CRITICAL)