diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000..10ca4db
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,3 @@
+# Add funding model platform
+
+buy_me_a_coffee: geezacoleman
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..66613ac
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,135 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+pip-wheel-metadata/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+.python-version
+
+# pipenv
+# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+# However, in case of collaboration, if having platform-specific dependencies or dependencies
+# having no cross-platform support, pipenv may install dependencies that don't work, or not
+# install all needed dependencies.
+#Pipfile.lock
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
diff --git a/3D Models/Compact OWL/Backplate - 2 x Amphenol receptacle.stl b/3D Models/Compact OWL/Backplate - 2 x Amphenol receptacle.stl
new file mode 100644
index 0000000..a878655
Binary files /dev/null and b/3D Models/Compact OWL/Backplate - 2 x Amphenol receptacle.stl differ
diff --git a/3D Models/Compact OWL/Backplate - blank.stl b/3D Models/Compact OWL/Backplate - blank.stl
new file mode 100644
index 0000000..092d5a5
Binary files /dev/null and b/3D Models/Compact OWL/Backplate - blank.stl differ
diff --git a/3D Models/Compact OWL/Backplate - gland.stl b/3D Models/Compact OWL/Backplate - gland.stl
new file mode 100644
index 0000000..ddf0b39
Binary files /dev/null and b/3D Models/Compact OWL/Backplate - gland.stl differ
diff --git a/3D Models/Compact OWL/Backplate - receptacle and ethernet.stl b/3D Models/Compact OWL/Backplate - receptacle and ethernet.stl
new file mode 100644
index 0000000..e4ee9e5
Binary files /dev/null and b/3D Models/Compact OWL/Backplate - receptacle and ethernet.stl differ
diff --git a/3D Models/Compact OWL/Backplate - receptacle only.stl b/3D Models/Compact OWL/Backplate - receptacle only.stl
new file mode 100644
index 0000000..f0084bb
Binary files /dev/null and b/3D Models/Compact OWL/Backplate - receptacle only.stl differ
diff --git a/3D Models/Compact OWL/Camera Mount.stl b/3D Models/Compact OWL/Camera Mount.stl
new file mode 100644
index 0000000..65ac6d6
Binary files /dev/null and b/3D Models/Compact OWL/Camera Mount.stl differ
diff --git a/3D Models/Compact OWL/Frontplate.stl b/3D Models/Compact OWL/Frontplate.stl
new file mode 100644
index 0000000..3255c1f
Binary files /dev/null and b/3D Models/Compact OWL/Frontplate.stl differ
diff --git a/3D Models/Compact OWL/Lens Mount.stl b/3D Models/Compact OWL/Lens Mount.stl
new file mode 100644
index 0000000..dc18462
Binary files /dev/null and b/3D Models/Compact OWL/Lens Mount.stl differ
diff --git a/3D Models/Compact OWL/Main Body.stl b/3D Models/Compact OWL/Main Body.stl
new file mode 100644
index 0000000..3058335
Binary files /dev/null and b/3D Models/Compact OWL/Main Body.stl differ
diff --git a/3D Models/Compact OWL/Tray - base.stl b/3D Models/Compact OWL/Tray - base.stl
new file mode 100644
index 0000000..fc4808b
Binary files /dev/null and b/3D Models/Compact OWL/Tray - base.stl differ
diff --git a/3D Models/Compact OWL/Tray - lens holder.stl b/3D Models/Compact OWL/Tray - lens holder.stl
new file mode 100644
index 0000000..666c1ae
Binary files /dev/null and b/3D Models/Compact OWL/Tray - lens holder.stl differ
diff --git a/3D Models/Controllers/Advanced Controller - Base - 4 OWL.stl b/3D Models/Controllers/Advanced Controller - Base - 4 OWL.stl
new file mode 100644
index 0000000..89e0e04
Binary files /dev/null and b/3D Models/Controllers/Advanced Controller - Base - 4 OWL.stl differ
diff --git a/3D Models/Controllers/Advanced Controller - Base - Double OWL.stl b/3D Models/Controllers/Advanced Controller - Base - Double OWL.stl
new file mode 100644
index 0000000..3e60950
Binary files /dev/null and b/3D Models/Controllers/Advanced Controller - Base - Double OWL.stl differ
diff --git a/3D Models/Controllers/Advanced Controller - Base - Single OWL.stl b/3D Models/Controllers/Advanced Controller - Base - Single OWL.stl
new file mode 100644
index 0000000..31ad112
Binary files /dev/null and b/3D Models/Controllers/Advanced Controller - Base - Single OWL.stl differ
diff --git a/3D Models/Controllers/Advanced Controller - Top - 4 OWL.stl b/3D Models/Controllers/Advanced Controller - Top - 4 OWL.stl
new file mode 100644
index 0000000..dea7909
Binary files /dev/null and b/3D Models/Controllers/Advanced Controller - Top - 4 OWL.stl differ
diff --git a/3D Models/Controllers/Advanced Controller - Top - Double OWL.stl b/3D Models/Controllers/Advanced Controller - Top - Double OWL.stl
new file mode 100644
index 0000000..748355d
Binary files /dev/null and b/3D Models/Controllers/Advanced Controller - Top - Double OWL.stl differ
diff --git a/3D Models/Controllers/Advanced Controller - Top - Single OWL.stl b/3D Models/Controllers/Advanced Controller - Top - Single OWL.stl
new file mode 100644
index 0000000..94920a0
Binary files /dev/null and b/3D Models/Controllers/Advanced Controller - Top - Single OWL.stl differ
diff --git a/3D Models/Controllers/Ute Controller - Base.stl b/3D Models/Controllers/Ute Controller - Base.stl
new file mode 100644
index 0000000..80a9237
Binary files /dev/null and b/3D Models/Controllers/Ute Controller - Base.stl differ
diff --git a/3D Models/Controllers/Ute Controller - Top.stl b/3D Models/Controllers/Ute Controller - Top.stl
new file mode 100644
index 0000000..45e0ba4
Binary files /dev/null and b/3D Models/Controllers/Ute Controller - Top.stl differ
diff --git a/3D Models/Camera mount.stl b/3D Models/Original OWL/Camera mount.stl
similarity index 100%
rename from 3D Models/Camera mount.stl
rename to 3D Models/Original OWL/Camera mount.stl
diff --git a/3D Models/Enclosure - cable gland.stl b/3D Models/Original OWL/Enclosure - cable gland.stl
similarity index 100%
rename from 3D Models/Enclosure - cable gland.stl
rename to 3D Models/Original OWL/Enclosure - cable gland.stl
diff --git a/3D Models/Enclosure - cover.stl b/3D Models/Original OWL/Enclosure - cover.stl
similarity index 100%
rename from 3D Models/Enclosure - cover.stl
rename to 3D Models/Original OWL/Enclosure - cover.stl
diff --git a/3D Models/Enclosure - single connector.stl b/3D Models/Original OWL/Enclosure - single connector.stl
similarity index 100%
rename from 3D Models/Enclosure - single connector.stl
rename to 3D Models/Original OWL/Enclosure - single connector.stl
diff --git a/3D Models/Enclosure plug.stl b/3D Models/Original OWL/Enclosure plug.stl
similarity index 100%
rename from 3D Models/Enclosure plug.stl
rename to 3D Models/Original OWL/Enclosure plug.stl
diff --git a/3D Models/Raspberry Pi mount.stl b/3D Models/Original OWL/Raspberry Pi mount.stl
similarity index 100%
rename from 3D Models/Raspberry Pi mount.stl
rename to 3D Models/Original OWL/Raspberry Pi mount.stl
diff --git a/3D Models/Relay control board mount.stl b/3D Models/Original OWL/Relay control board mount.stl
similarity index 100%
rename from 3D Models/Relay control board mount.stl
rename to 3D Models/Original OWL/Relay control board mount.stl
diff --git a/3D Models/Solenoid mount - back.stl b/3D Models/Original OWL/Solenoid mount - back.stl
similarity index 100%
rename from 3D Models/Solenoid mount - back.stl
rename to 3D Models/Original OWL/Solenoid mount - back.stl
diff --git a/3D Models/Solenoid mount - front.stl b/3D Models/Original OWL/Solenoid mount - front.stl
similarity index 100%
rename from 3D Models/Solenoid mount - front.stl
rename to 3D Models/Original OWL/Solenoid mount - front.stl
diff --git a/3D Models/Solenoid mount - mid.stl b/3D Models/Original OWL/Solenoid mount - mid.stl
similarity index 100%
rename from 3D Models/Solenoid mount - mid.stl
rename to 3D Models/Original OWL/Solenoid mount - mid.stl
diff --git a/3D Models/Voltage regulator mount.stl b/3D Models/Original OWL/Voltage regulator mount.stl
similarity index 100%
rename from 3D Models/Voltage regulator mount.stl
rename to 3D Models/Original OWL/Voltage regulator mount.stl
diff --git a/Interface/Readme.md b/Interface/Readme.md
new file mode 100644
index 0000000..99124c1
--- /dev/null
+++ b/Interface/Readme.md
@@ -0,0 +1,311 @@
+# MQTT Command Interface & Slave Controller System
+
+This project provides a centralized MQTT command interface (publisher) and a slave controller (subscriber) that communicate via an MQTT broker. The command interface is built with Tkinter and acts as a graphical user interface (similar to a Node‑RED dashboard), while slave devices receive and process commands intended for them based on their unique slave IDs.
+
+---
+
+## Table of Contents
+
+1. [MQTT Broker Setup](#mqtt-broker-setup)
+ - [Installing and Running an MQTT Broker on Windows](#installing-and-running-an-mqtt-broker-on-windows)
+ - [Setting Up an MQTT Broker on Raspberry Pi](#setting-up-an-mqtt-broker-on-raspberry-pi)
+ - [Creating a WiFi Access Point on Raspberry Pi for the Broker](#creating-a-wifi-access-point-on-raspberry-pi-for-the-broker)
+2. [Publisher (Command Interface) Setup](#publisher-command-interface-setup)
+3. [Slave Device Setup](#slave-device-setup)
+4. [How It Works](#how-it-works)
+5. [Extending the System](#extending-the-system)
+6. [Troubleshooting](#troubleshooting)
+7. [License and Credits](#license-and-credits)
+
+---
+
+## MQTT Broker Setup
+
+### Installing and Running an MQTT Broker on Windows
+
+1. **Download Mosquitto Broker for Windows:**
+ - Visit the [Mosquitto download page](https://mosquitto.org/download/) and download the Windows installer (choose the latest stable version).
+
+2. **Install Mosquitto:**
+ - Run the installer and follow the on-screen instructions. By default, Mosquitto will be installed in a folder such as `C:\Program Files\Mosquitto\`.
+
+3. **Configure Mosquitto (Optional):**
+ - Mosquitto comes with a default configuration file (`mosquitto.conf`). To use custom settings, open the file in a text editor.
+ - Ensure that the listener is set up on port `1883` (the default) and that no authentication is enforced if you want to keep it simple.
+
+4. **Start the Broker:**
+ - Open a Command Prompt window with administrator privileges.
+ - Navigate to the Mosquitto installation directory (e.g., `cd "C:\Program Files\Mosquitto"`).
+ - Start the broker by running:
+ ```bash
+ mosquitto.exe -v
+ ```
+ - The `-v` flag enables verbose logging so you can see connection messages and any errors.
+
+5. **Test the Broker:**
+ - Use an MQTT client (such as MQTT.fx, MQTT Explorer, or even the command line) to subscribe to a topic (e.g., `commands/can`) and publish a test message to verify the broker works.
+
+### Setting Up an MQTT Broker on Raspberry Pi
+
+1. **Install Mosquitto on Raspberry Pi:**
+ - Open a terminal on your Raspberry Pi.
+ - Update your package lists:
+ ```bash
+ sudo apt update
+ ```
+ - Install Mosquitto and the clients package:
+ ```bash
+ sudo apt install mosquitto mosquitto-clients
+ ```
+ - Mosquitto should start automatically after installation.
+
+2. **Configure Mosquitto (Optional):**
+ - The default configuration usually works fine. If needed, edit the configuration file (often at `/etc/mosquitto/mosquitto.conf`) to adjust settings like listener ports or authentication.
+
+3. **Test the Broker on Raspberry Pi:**
+ - In one terminal, subscribe to a topic:
+ ```bash
+ mosquitto_sub -h localhost -t "commands/can" -v
+ ```
+ - In another terminal, publish a test message:
+ ```bash
+ mosquitto_pub -h localhost -t "commands/can" -m "Hello from Raspberry Pi"
+ ```
+
+### Creating a WiFi Access Point on Raspberry Pi for the Broker
+
+If you want your Raspberry Pi to also act as a WiFi access point (so that slave devices or other computers can connect directly to it to access the MQTT broker), follow these steps:
+
+1. **Install Required Software:**
+ - Install `hostapd` and `dnsmasq`:
+ ```bash
+ sudo apt update
+ sudo apt install hostapd dnsmasq
+ ```
+
+2. **Stop Services Temporarily:**
+ - Disable the services while configuring:
+ ```bash
+ sudo systemctl stop hostapd
+ sudo systemctl stop dnsmasq
+ ```
+
+3. **Configure a Static IP for the Wireless Interface:**
+ - Edit the DHCP daemon configuration for `dhcpcd`:
+ ```bash
+ sudo nano /etc/dhcpcd.conf
+ ```
+ - Add the following lines at the end:
+ ```
+ interface wlan0
+ static ip_address=192.168.4.1/24
+ nohook wpa_supplicant
+ ```
+ - Save and exit (`CTRL+O`, `ENTER`, then `CTRL+X`).
+
+4. **Configure hostapd:**
+ - Create or edit the hostapd configuration file:
+ ```bash
+ sudo nano /etc/hostapd/hostapd.conf
+ ```
+ - Add the following configuration (edit `ssid` and `wpa_passphrase` as desired):
+ ```
+ interface=wlan0
+ driver=nl80211
+ ssid=RaspberryPi_AP
+ hw_mode=g
+ channel=7
+ wmm_enabled=0
+ macaddr_acl=0
+ auth_algs=1
+ ignore_broadcast_ssid=0
+ wpa=2
+ wpa_passphrase=YourStrongPassword
+ wpa_key_mgmt=WPA-PSK
+ wpa_pairwise=TKIP
+ rsn_pairwise=CCMP
+ ```
+ - Edit `/etc/default/hostapd` to point to this configuration file. Uncomment and change the DAEMON_CONF variable:
+ ```bash
+ sudo nano /etc/default/hostapd
+ ```
+ Change to:
+ ```
+ DAEMON_CONF="/etc/hostapd/hostapd.conf"
+ ```
+
+5. **Configure dnsmasq:**
+ - Rename the default configuration file:
+ ```bash
+ sudo mv /etc/dnsmasq.conf /etc/dnsmasq.conf.orig
+ ```
+ - Create a new dnsmasq configuration file:
+ ```bash
+ sudo nano /etc/dnsmasq.conf
+ ```
+ - Add the following:
+ ```
+ interface=wlan0 # Use the correct wireless interface name
+ dhcp-range=192.168.4.2,192.168.4.20,255.255.255.0,24h
+ ```
+ - Save and exit.
+
+6. **Enable and Start the Access Point:**
+ - Restart the DHCP service:
+ ```bash
+ sudo service dhcpcd restart
+ ```
+ - Start `hostapd` and `dnsmasq`:
+ ```bash
+ sudo systemctl start hostapd
+ sudo systemctl start dnsmasq
+ ```
+ - Enable both services to start on boot:
+ ```bash
+ sudo systemctl enable hostapd
+ sudo systemctl enable dnsmasq
+ ```
+
+7. **Test the Access Point:**
+ - From another device, search for the WiFi network (SSID: `RaspberryPi_AP`), connect using the password you set, and then test connectivity by pinging `192.168.4.1`.
+ - MQTT clients on this network can now connect to your Raspberry Pi broker using the IP address `192.168.4.1` on port `1883`.
+
+---
+
+## Publisher (Command Interface) Setup
+
+1. **Verify MQTT Settings:**
+ - Open `mqtt_interface.py` and ensure the MQTT broker address and port (e.g., `localhost` and `1883`) are correct.
+2. **Edit Slave IDs:**
+ - Modify the list of slave IDs as needed.
+3. **Run the Script:**
+ ```bash
+ python mqtt_interface.py
+ ```
+
+---
+
+## Slave Device Setup
+
+needs more information will update.
+
+1. **Prepare the Slave Device:**
+ - Ensure the device (for example, a Raspberry Pi or Windows computer) has Python 3 installed and the required packages (`paho-mqtt`).
+
+2. **Configure the Slave Script:**
+ - Open `input_manager.py`.
+ - Set the `SLAVE_ID` variable to a unique identifier for this slave (e.g., `"0x201"`).
+ - Update configuration file paths if necessary.
+ - Replace the dummy classes (`DummyOwl` and `DummyStatusIndicator`) with your actual implementations.
+
+3. **Run the Script:**
+ ```bash
+ python slave_controller.py
+ ```
+
+---
+
+## How It Works
+
+- **Publisher Script:**
+ Sends JSON payloads to `"commands/can"`. Example:
+ ```json
+ { "command": "recording", "state": "on" }
+ ```
+
+- **Slave Script:**
+ Subscribes to `"commands/can"` and processes only the commands where `"slave"` matches its `SLAVE_ID`.
+
+---
+
+## Extending the System
+
+- **Add Commands:**
+ Define them in both `mqtt_interface.py` and `slave_controller.py`.
+
+- **Add More Slaves:**
+ Run `slave_controller.py` on each additional device with a unique `SLAVE_ID`.
+
+---
+
+# MQTT Interface & USB Section Control
+
+This Python application provides a flexible interface to control and monitor agricultural equipment using MQTT. It features two modes of operation:
+
+## Modes of Operation
+
+1. **GUI Mode (`--mode gui`)**
+ Launches a Tkinter-based graphical interface for manual control of nozzles, spot spraying, recording, and other parameters. Ideal for real-time control and testing.
+
+2. **Serial Mode (`--mode serial`)**
+ Reads relay section control data from an AgOpenGPS-compatible device over USB (`/dev/ttyUSB0`) and publishes the section states as JSON MQTT messages. This mode allows relay state automation without relying on GPIO pins.
+
+## Features
+
+- **Tkinter GUI**
+ - Turn all nozzles on/off
+ - Start/stop recording
+ - Adjust sensitivity and file settings
+ - Toggle spot spraying for individual slave sections
+
+- **USB Serial Integration**
+ - Monitors AgOpenGPS relay messages (PGN 239)
+ - Converts relay bytes to 16-bit section state array
+ - Publishes state updates to `commands/can` over MQTT
+
+- **MQTT Messaging**
+ - Consistent MQTT structure across modes
+ - Publishes to: `commands/can`
+ - JSON payloads like:
+ ```json
+ {
+ "command": "relay_states",
+ "states": [1, 0, 0, 1, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0]
+ }
+ ```
+
+## Requirements
+
+- Python 3.7+
+- Dependencies:
+ ```bash
+ pip install paho-mqtt pyserial
+ ```
+
+## Running the Application
+
+### GUI Mode:
+```bash
+python mqtt_interface_combined.py --mode gui
+```
+
+### Serial Mode (auto-relay publishing):
+```bash
+python mqtt_interface_combined.py --mode serial --port /dev/ttyUSB0
+```
+
+## Use Cases
+
+- Enable spot spray control on Raspberry Pi without GPIO logic.
+- Interface AgOpenGPS relay messages directly with MQTT-based field control logic.
+- Manual control interface for local override or test setups.
+
+## Troubleshooting
+
+- **Broker not connecting:**
+ Confirm broker is running and reachable at the configured address and port.
+
+- **GUI not opening:**
+ Ensure you’re using a system that supports GUI (or use VNC or SSH X-forwarding on Raspberry Pi).
+
+- **No commands received:**
+ Verify that the `"slave"` field is present and matches the actual `SLAVE_ID`.
+
+---
+
+## License and Credits
+
+This system is provided as-is. You are free to use and modify for educational or commercial projects.
+
+Happy coding!
+
diff --git a/Interface/Sectioncontrol.py b/Interface/Sectioncontrol.py
new file mode 100644
index 0000000..bcf9331
--- /dev/null
+++ b/Interface/Sectioncontrol.py
@@ -0,0 +1,153 @@
+import tkinter as tk
+from tkinter import ttk
+import paho.mqtt.client as mqtt
+import json
+import logging
+import time
+import serial
+import threading
+import argparse
+import sys
+
+# Configure logging
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger("MQTT_Interface")
+
+# MQTT setup
+BROKER = "localhost"
+PORT = 1883
+TOPIC = "commands/can"
+
+# Global MQTT client
+mqtt_client = mqtt.Client("MQTT_Combined_Client")
+
+def send_mqtt(command, **kwargs):
+ """Send MQTT message."""
+ msg = {"command": command}
+ msg.update(kwargs)
+ payload = json.dumps(msg)
+ mqtt_client.publish(TOPIC, payload)
+ logger.info("Published: %s", payload)
+
+# --- Section Relay Serial Thread (USB) ---
+def section_control_thread(serial_port='/dev/ttyUSB0', baudrate=38400):
+ try:
+ ser = serial.Serial(serial_port, baudrate, timeout=1)
+ except Exception as e:
+ logger.error(f"Could not open serial port: {e}")
+ return
+
+ while True:
+ try:
+ if ser.in_waiting >= 14:
+ header = ser.read(2)
+ if header == b'\x80\x81':
+ ser.read(1)
+ pgn = ord(ser.read(1))
+ length = ord(ser.read(1))
+
+ if pgn == 239:
+ payload = ser.read(length)
+ if len(payload) >= 9:
+ relay_lo = payload[6]
+ relay_hi = payload[7]
+
+ relay_states = []
+ for i in range(8):
+ relay_states.append((relay_lo >> i) & 1)
+ for i in range(8):
+ relay_states.append((relay_hi >> i) & 1)
+
+ send_mqtt("relay_states", states=relay_states)
+
+ time.sleep(0.2)
+ except Exception as e:
+ logger.error(f"Error in serial loop: {e}")
+ break
+
+# --- GUI Class ---
+class MQTTInterface:
+ def __init__(self, master, mqtt_client, slave_ids):
+ self.master = master
+ self.mqtt_client = mqtt_client
+ self.slave_ids = slave_ids
+
+ master.title("MQTT Command Interface")
+
+ global_frame = ttk.LabelFrame(master, text="Global Commands")
+ global_frame.grid(row=0, column=0, padx=10, pady=10, sticky="ew")
+
+ ttk.Button(global_frame, text="All Nozzles On", command=self.all_nozzles_on).grid(row=0, column=0, padx=5, pady=5)
+ ttk.Button(global_frame, text="All Nozzles Off", command=self.all_nozzles_off).grid(row=0, column=1, padx=5, pady=5)
+
+ ttk.Button(global_frame, text="Recording On", command=self.recording_on).grid(row=1, column=0, padx=5, pady=5)
+ ttk.Button(global_frame, text="Recording Off", command=self.recording_off).grid(row=1, column=1, padx=5, pady=5)
+
+ ttk.Label(global_frame, text="Sensitivity").grid(row=2, column=0, padx=5, pady=5)
+ self.sensitivity_slider = ttk.Scale(global_frame, from_=1, to=10, orient="horizontal")
+ self.sensitivity_slider.set(5)
+ self.sensitivity_slider.grid(row=2, column=1, padx=5, pady=5)
+ ttk.Button(global_frame, text="Set Sensitivity", command=self.set_sensitivity).grid(row=2, column=2, padx=5, pady=5)
+
+ ttk.Label(global_frame, text="Files").grid(row=3, column=0, padx=5, pady=5)
+ self.files_slider = ttk.Scale(global_frame, from_=1, to=10, orient="horizontal")
+ self.files_slider.set(5)
+ self.files_slider.grid(row=3, column=1, padx=5, pady=5)
+ ttk.Button(global_frame, text="Set Files", command=self.set_files).grid(row=3, column=2, padx=5, pady=5)
+
+ spot_frame = ttk.LabelFrame(master, text="Spot Spray Commands")
+ spot_frame.grid(row=1, column=0, padx=10, pady=10, sticky="ew")
+
+ ttk.Label(spot_frame, text="Slave ID:").grid(row=0, column=0, padx=5, pady=5)
+ self.slave_combo = ttk.Combobox(spot_frame, values=self.slave_ids, state="readonly")
+ self.slave_combo.current(0)
+ self.slave_combo.grid(row=0, column=1, padx=5, pady=5)
+
+ ttk.Button(spot_frame, text="Spot Spray On", command=self.spot_spray_on).grid(row=1, column=0, padx=5, pady=5)
+ ttk.Button(spot_frame, text="Spot Spray Off", command=self.spot_spray_off).grid(row=1, column=1, padx=5, pady=5)
+
+ def publish_command(self, command, **kwargs):
+ msg = {"command": command}
+ msg.update(kwargs)
+ payload = json.dumps(msg)
+ self.mqtt_client.publish("commands/can", payload, qos=1, retain=False)
+ logger.info("Published: %s", payload)
+
+ def all_nozzles_on(self): self.publish_command("all_nozzles", state="on")
+ def all_nozzles_off(self): self.publish_command("all_nozzles", state="off")
+ def recording_on(self): self.publish_command("recording", state="on")
+ def recording_off(self): self.publish_command("recording", state="off")
+ def set_sensitivity(self): self.publish_command("sensitivity", value=self.sensitivity_slider.get())
+ def set_files(self): self.publish_command("files", value=self.files_slider.get())
+ def spot_spray_on(self): self.publish_command("spot_spray", slave=self.slave_combo.get(), state="on")
+ def spot_spray_off(self): self.publish_command("spot_spray", slave=self.slave_combo.get(), state="off")
+
+# --- Main ---
+def main():
+ parser = argparse.ArgumentParser(description="MQTT Interface for AgOpenGPS and GUI control")
+ parser.add_argument("--mode", choices=["gui", "serial"], default="gui", help="Select mode: gui or serial")
+ parser.add_argument("--port", default="/dev/ttyUSB0", help="Serial port for AgOpenGPS")
+ args = parser.parse_args()
+
+ mqtt_client.connect(BROKER, PORT, 60)
+ mqtt_client.loop_start()
+
+ if args.mode == "gui":
+ root = tk.Tk()
+ app = MQTTInterface(root, mqtt_client, ["0x201", "0x202", "0x203", "0x204"])
+ root.mainloop()
+ mqtt_client.loop_stop()
+ mqtt_client.disconnect()
+ elif args.mode == "serial":
+ thread = threading.Thread(target=section_control_thread, args=(args.port,), daemon=True)
+ thread.start()
+ try:
+ while thread.is_alive():
+ time.sleep(1)
+ except KeyboardInterrupt:
+ print("Exiting...")
+ mqtt_client.loop_stop()
+ mqtt_client.disconnect()
+
+if __name__ == "__main__":
+ main()
diff --git a/Interface/mqtt_interface.py b/Interface/mqtt_interface.py
new file mode 100644
index 0000000..f7a62d4
--- /dev/null
+++ b/Interface/mqtt_interface.py
@@ -0,0 +1,137 @@
+import tkinter as tk
+from tkinter import ttk
+import paho.mqtt.client as mqtt
+import json
+import logging
+
+# Configure logging.
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger("MQTT_Interface")
+
+class MQTTInterface:
+ def __init__(self, master, mqtt_client, slave_ids):
+ self.master = master
+ self.mqtt_client = mqtt_client
+ self.slave_ids = slave_ids
+
+ master.title("MQTT Command Interface")
+
+ # -------------------------
+ # Global Commands Frame
+ # -------------------------
+ global_frame = ttk.LabelFrame(master, text="Global Commands")
+ global_frame.grid(row=0, column=0, padx=10, pady=10, sticky="ew")
+
+ # All Nozzles On/Off
+ btn_all_nozzles_on = ttk.Button(global_frame, text="All Nozzles On", command=self.all_nozzles_on)
+ btn_all_nozzles_on.grid(row=0, column=0, padx=5, pady=5)
+ btn_all_nozzles_off = ttk.Button(global_frame, text="All Nozzles Off", command=self.all_nozzles_off)
+ btn_all_nozzles_off.grid(row=0, column=1, padx=5, pady=5)
+
+ # Recording On/Off
+ btn_recording_on = ttk.Button(global_frame, text="Recording On", command=self.recording_on)
+ btn_recording_on.grid(row=1, column=0, padx=5, pady=5)
+ btn_recording_off = ttk.Button(global_frame, text="Recording Off", command=self.recording_off)
+ btn_recording_off.grid(row=1, column=1, padx=5, pady=5)
+
+ # Sensitivity Slider
+ sensitivity_label = ttk.Label(global_frame, text="Sensitivity")
+ sensitivity_label.grid(row=2, column=0, padx=5, pady=5)
+ self.sensitivity_slider = ttk.Scale(global_frame, from_=1, to=10, orient="horizontal")
+ self.sensitivity_slider.set(5) # Default value
+ self.sensitivity_slider.grid(row=2, column=1, padx=5, pady=5)
+ btn_set_sensitivity = ttk.Button(global_frame, text="Set Sensitivity", command=self.set_sensitivity)
+ btn_set_sensitivity.grid(row=2, column=2, padx=5, pady=5)
+
+ # Files Slider
+ files_label = ttk.Label(global_frame, text="Files")
+ files_label.grid(row=3, column=0, padx=5, pady=5)
+ self.files_slider = ttk.Scale(global_frame, from_=1, to=10, orient="horizontal")
+ self.files_slider.set(5) # Default value
+ self.files_slider.grid(row=3, column=1, padx=5, pady=5)
+ btn_set_files = ttk.Button(global_frame, text="Set Files", command=self.set_files)
+ btn_set_files.grid(row=3, column=2, padx=5, pady=5)
+
+ # -------------------------
+ # Spot Spray Commands Frame
+ # -------------------------
+ spot_frame = ttk.LabelFrame(master, text="Spot Spray Commands")
+ spot_frame.grid(row=1, column=0, padx=10, pady=10, sticky="ew")
+
+ # Slave ID selection (Combobox)
+ slave_label = ttk.Label(spot_frame, text="Slave ID:")
+ slave_label.grid(row=0, column=0, padx=5, pady=5)
+ self.slave_combo = ttk.Combobox(spot_frame, values=self.slave_ids, state="readonly")
+ self.slave_combo.current(0) # Default to the first slave.
+ self.slave_combo.grid(row=0, column=1, padx=5, pady=5)
+
+ # Spot Spray On/Off buttons
+ btn_spot_on = ttk.Button(spot_frame, text="Spot Spray On", command=self.spot_spray_on)
+ btn_spot_on.grid(row=1, column=0, padx=5, pady=5)
+ btn_spot_off = ttk.Button(spot_frame, text="Spot Spray Off", command=self.spot_spray_off)
+ btn_spot_off.grid(row=1, column=1, padx=5, pady=5)
+
+ def publish_command(self, command, **kwargs):
+ """Compose and publish a JSON command message to the MQTT topic."""
+ message = {"command": command}
+ message.update(kwargs)
+ payload = json.dumps(message)
+ self.mqtt_client.publish("commands/can", payload, qos=1, retain=False)
+ logger.info("Published: %s", payload)
+
+ # Global command methods.
+ def all_nozzles_on(self):
+ self.publish_command("all_nozzles", state="on")
+
+ def all_nozzles_off(self):
+ self.publish_command("all_nozzles", state="off")
+
+ def recording_on(self):
+ self.publish_command("recording", state="on")
+
+ def recording_off(self):
+ self.publish_command("recording", state="off")
+
+ def set_sensitivity(self):
+ value = self.sensitivity_slider.get()
+ self.publish_command("sensitivity", value=value)
+
+ def set_files(self):
+ value = self.files_slider.get()
+ self.publish_command("files", value=value)
+
+ # Spot spray command methods.
+ def spot_spray_on(self):
+ slave = self.slave_combo.get()
+ self.publish_command("spot_spray", slave=slave, state="on")
+
+ def spot_spray_off(self):
+ slave = self.slave_combo.get()
+ self.publish_command("spot_spray", slave=slave, state="off")
+
+def run_publisher():
+ # MQTT broker settings (update if necessary).
+ broker = "localhost"
+ port = 1883
+
+ # Create and set up the MQTT client.
+ client = mqtt.Client("MQTT_Interface_Client")
+ client.connect(broker, port, 60)
+ client.loop_start()
+
+ # Define the list of available slave IDs.
+ slave_ids = ["0x201", "0x202", "0x203", "0x204"]
+
+ # Set up the main Tkinter window.
+ root = tk.Tk()
+ app = MQTTInterface(root, client, slave_ids)
+
+ # Run the GUI event loop.
+ root.mainloop()
+
+ # Clean up: stop the MQTT loop and disconnect.
+ client.loop_stop()
+ client.disconnect()
+
+if __name__ == "__main__":
+ run_publisher()
diff --git a/README.md b/README.md
index 476434a..58c54ba 100644
--- a/README.md
+++ b/README.md
@@ -2,39 +2,115 @@
-Welcome to the OpenWeedLocator (OWL) project, an opensource hardware and software green-on-brown weed detector that uses entirely off-the-shelf componentry, very simple green-detection algorithms and entirely 3D printable parts. OWL integrates weed detection on a Raspberry Pi with a relay control board in a custom designed case so you can attach any 12V solenoid, relay, lightbulb or device for low-cost, simple and opensource site-specific weed control. Projects to date have seen OWL mounted on robots and vehicles for spot spraying!
+# OpenWeedLocator
-On the weed detection front, a range of algorithms have been provided, each with advantages and disadvantages for your use case. They include ExG (excess green 2g - r - b, developed by Woebbecke et al. 1995), a hue, saturation and value (HSV) threshold and a combined ExG + HSV algorithm. These algorithms have all been tested in a wide range of conditions. The article has now been published in [Scientific Reports (open access)](https://www.nature.com/articles/s41598-021-03858-9).
+Welcome to the OpenWeedLocator (OWL) project, an opensource hardware and software weed detector that uses
+entirely off-the-shelf componentry, very simple green-detection algorithms (with capacity to upgrade to
+in-crop detection) and 3D printable parts. OWL integrates weed detection on a Raspberry Pi with a relay
+control board or custom driver board, in a custom designed case so you can attach any 12V solenoid, relay, lightbulb or
+device for low-cost, simple and open-source site-specific weed control. Projects to date have seen OWL mounted on robots,
+vehicles and bicycles for spot spraying. For the latest ideas and news, check out the [Discussion](https://github.com/geezacoleman/OpenWeedLocator/discussions) tab.
-Repository DOI: [](https://zenodo.org/badge/latestdoi/399194159)
+### News
+**14-02-2025** - Complete OWL software installation guide now on YouTube
-**Note**: The project is in the process of transitioning to picamera2 on the [picamera2 branch](https://github.com/geezacoleman/OpenWeedLocator/tree/picamera2).
+
+
+
-Internal electronics | Fitted module - vehicle | Fitted module - robot
-:-------------------------:|:-------------------------: |:-------------------------:
- |  | 
+
+
+**OWL Newsletter**
+
+Follow updates for the OWL through the new, [OpenSourceAg Newsletter](https://openagtech.beehiiv.com/) a new edition every two weeks.
+
+
+
+**08-05-2024** - Check out the latest Compact OWL enclosures!
+
+| | Front | Back |
+|-------------------------------------------|--------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------|
+| Official OWL extruded aluminium enclosure |  |  |
+| 3D printable enclosure |  |  |
+
+Find all the 3D printable files [on the OWL repository](#3d-printing) or download them from
+[Printables](https://www.printables.com/model/875853-raspberry-pi-rugged-imaging-enclosure).
+
+**13-04-2024** - OpenWeedLocator now supports Raspberry Pi 5 and picamera2! Improvements include:
+* support for both picamera and picamera2
+* implementation of a `config/config.ini` approach to setting detection parameters
+* cleaner, more consistent code
+
+**10-04-2024** - v2.1 of the [OWL driver board released](https://github.com/geezacoleman/owl-driver-board).
+* simplifies assembly
+* more robust and improved performance
+
+### OWLs in Action
+
+| OWL on a vehicle | OWL on the AgroIntelli Robotti | OWL on the Agerris Digital Farmhand | OWL on a bicycle |
+|:-----------------------------------------------------------------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------------------------------------:|-------------------------------------|------------------|
+|  |  |  |  |
+
+### Official Publications
+
+#### OpenWeedLocator (OWL): An open-source, low-cost device for fallow weed detection
+
+This is the original OWL publication, released
+in [Scientific Reports (open access)](https://www.nature.com/articles/s41598-021-03858-9). A range of green detection
+algorithms were tested including ExG (excess green 2g - r - b, developed by Woebbecke et al. 1995), a hue, saturation
+and value (HSV) threshold and a combined ExG + HSV algorithm. If you use the OWL in your research please consider citing
+this publication.
+
+#### Investigating image-based fallow weed detection performance on Raphanus sativus and Avena sativa at speeds up to 30 km/h
+
+The performance of the OWL from 5 - 30 km/h with different cameras and on broadleaf and grass 'weeds' was tested and
+published
+in [Computers and Electronics in Agriculture](https://www.sciencedirect.com/science/article/pii/S0168169923008074). The
+current Raspberry Pi HQ Camera + latest software combination provided a recall of 74.8% at 5 km/h and 50.5 % at 30 km/h.
+Recall of up to 95.7% at 5 km/h was achieved by the global shutter Arducam AR0234.
+
+Repository DOI: [](https://zenodo.org/badge/latestdoi/399194159)
# Overview
+
* [OWL Use Cases](#owl-use-cases)
* [Community Development](#community-development-and-contribution)
* [Hardware Requirements](#hardware-requirements)
- - [Hardware Assembly](#hardware-assembly)
+ - [Hardware Assembly](#hardware-assembly)
+ - [Single Board Computer (SBC) Options](#sbc-options)
* [Software Installation](#software)
- - [Quick Method](#quick-method)
- - [Detailed Method](#detailed-method)
- - [Changing Detection Settings](#changing-detection-settings)
- - [Installing on non-Raspberry Pi Computers](#non-raspberry-pi-installation)
+ - [Changing Detection Settings](#changing-detection-settings)
+ - [Green-on-Green (almost) :eyes::dart::seedling:](#green-on-green)
+ - [Installing on non-Raspberry Pi Computers](#non-raspberry-pi-installation)
+* [Controller](#connecting-a-controller)
* [3D Printing](#3d-printing)
* [Updating OWL](#updating-owl)
- - [Version History](#version-history)
+ - [Version History](#version-history)
* [Troubleshooting](#troubleshooting)
* [Citing OWL](#citing-owl)
* [Acknowledgements](#acknowledgements)
* [References](#references)
+### Manuals
+
+If you prefer a hardcopy version of these instructions, you can view and download the PDF using one of the links below.
+These will be updated as major changes are made. All older versions will be retained within the `docs` folder.
+
+**Current**
+
+* [2024-05-28 - Download OWL manual](docs/20240528_owl_readme.pdf)
+
+[View all versions](docs)
+
# OWL Use Cases
+
## Vehicle-mounted spot spraying
-The first, and most clear use case for the OWL is for the site-specific application of herbicide in fallow. As part of the development and testing of the unit, the OWL team designed and assembled a 2 m spot spraying boom, using two OWLs to control four 12 V solenoids each. The boom was mounted on the back of a ute/utility vehicle with the spray tank located in the tray and powered by a 12V car battery. Indicator lights for each nozzle were used to highlight more clearly when each solenoid had been activated for demonstration and testing purposes.
+
+The first, and most clear use case for the OWL is for the site-specific application of herbicide in fallow. As part of
+the development and testing of the unit, the OWL team designed and assembled a 2 m spot spraying boom, using two OWLs to
+control four 12 V solenoids each. The boom was mounted on the back of a ute/utility vehicle with the spray tank located
+in the tray and powered by a 12V car battery. Indicator lights for each nozzle were used to highlight more clearly when
+each solenoid had been activated for demonstration and testing purposes.
@@ -50,14 +126,22 @@ Strainer | TeeJet 50 mesh strainer | Protect spray tip from clogging/damage
Pump/tank | Northstar 12V 60L ATV Sprayer | 8.3 LPM 12V pump, 60L capacity, tray mounted
## Robot-mounted spot spraying
-A second system, identical to the first, was developed for the University of Sydney's Digifarm robot, the Agerris Digital Farm Hand. The system is in frequent use for the site-specific control of weeds in trial areas. It is powered by the 24V system on the robot, using a 24 - 12V DC/DC converter.
+
+A second system, identical to the first, was developed for the University of Sydney's Digifarm robot, the Agerris
+Digital Farm Hand. The system is in frequent use for the site-specific control of weeds in trial areas. It is powered by
+the 24V system on the robot, using a 24 - 12V DC/DC converter.
## Image data collection
-An updated image sampling method was added on 14/07/2022, which allows whole-image, cropped-to-bounding-box and square images saved on a set frequency. This means the OWL can now be used for image data collection much more easily than before. Example images for each method are provided below.
+
+The OWL can act as a high quality image data collection tool for developing image data training sets in realistic
+agricultural environments, attached to agricultural equipment. The approach allows whole-image, cropped-to-bounding-box
+and square images saved on a set frequency to a thumb drive.
+
+Settings are updated in the `config.ini` file.
| **Method** | **Code** | **Example** |
| ------------- | ------------- | ------------- |
@@ -67,80 +151,192 @@ An updated image sampling method was added on 14/07/2022, which allows whole-ima
| Deactivated (DEFAULT) | None | |
## Community development and contribution
-As more OWLs are built and fallow weed control systems developed, we would love to share the end results here. Please get in contact and we can upload images of the finished systems on this page.
-OWL now has a [Discussion](https://github.com/geezacoleman/OpenWeedLocator/discussions) page too. Use this for any ideas, suggestions, comments, completed units or other points you'd like to raise. If there's a bug or improvement, please raise an issue.
+As more OWLs are built and fallow weed control systems developed, we would love to share the end results here. Please
+get in contact and we can upload images of the finished systems on this page.
+
+Check out the OWL [Discussion](https://github.com/geezacoleman/OpenWeedLocator/discussions) page for any questions, suggestions ideas or feedback. If there's a bug or improvement,
+please raise an issue.
-Please review the [contribution page](CONTRIBUTING.md) for all the details on how to contribute and follow community guidelines.
+Please review the [contribution page](CONTRIBUTING.md) for all the details on how to contribute and follow community
+guidelines.
# Hardware Requirements
-A complete list of components is provided below. Further details on 3D models and hardware assembly are provided in subsequent sections. The quantities of each item below are for one OWL detection unit.
+The specific hardware requirements and details for each OWL format are provided below. There are two designs developed
+to ensure improved performance without compromising on the original simplicity of the OWL:
+1. Education layout (original OWL)
+2. Compact OWL
+ * extruded aluminium enclosure
+ * 3D printed enclosure
+
+| Original OWL | Compact OWL - Extruded Aluminium Enclosure | Compact OWL - 3D Printed Enclosure |
+|--------------|-----------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------|
+|  |  |  |
+
+All 3D models and hardware assembly guides are provided in subsequent sections. The quantities of each item below are for
+one OWL detection unit for each respective design.
+
+*Please note links provided in tables below to an example online retailer of each component for convenience only. There are
+certainly many other retailers that may be better suited and priced to your purposes and we encourage you to find local
+suppliers. Other types of connector, layout and design are also possible, which may change the parts required.*
+
+## Official OWL Hardware
+We now have a range of official OWL hardware available to build or purchase.
+
+#### OWL Enclosure
+The Official OWL Enclosure is an extruded aluminium enclosure with rubber seals and a glued with 2mm thick glass lens.
+It provides a more production friendly, durable and water/chemical resisant option over 3D printed plastic.
+
+#### OWL Driver Board
+The [Official OWL driver board](https://github.com/geezacoleman/owl-driver-board) combines the relay control board, power supply and wiring. It will be available for
+purchase soon, or you can use the files provided to order/make your own.
+
+## Hardware Lists
-List of hardware requirements
+Original OWL - Hardware List
-*Please note links are provided to an example online retailer of each component for convenience only. There are certainly many other retailers that may be better suited and priced to your purposes and we encourage you to find local suppliers. Other types of connector, layout and design are also possible, which may change the parts required.*
+### Original OWL
+The original OWL lays out all components in a flat design. It makes the connections and interactions within the system
+clear. It's a great educational tool to learn the parts required for a weed detection system and has served in the field
+as a functional weed detection system for a number of years.
+
+| **Component** | **Quantity** | **Link** |
+|-------------------------------------------------------------------------------|-------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| **Enclosure** | | |
+| Main Case (single Bulgin connector) | 1 | [STL File](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Original%20OWL/Enclosure%20-%20single%20connector.stl) |
+| *Main Case (cable glands)* | 1 | [STL File](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Original%20OWL/Enclosure%20-%20cable%20gland.stl) |
+| Main Cover | 1 | [STL File](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Original%20OWL/Enclosure%20-%20cover.stl) |
+| Raspberry Pi Mount | 1 | [STL File](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Original%20OWL/Raspberry%20Pi%20mount.stl) |
+| Relay Control Board Mount | 1 | [STL File](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Original%20OWL/Relay%20control%20board%20mount.stl) |
+| Voltage Regulator Mount | 1 | [STL File](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Original%20OWL/Voltage%20regulator%20mount.stl) |
+| Camera Mount | 1 | [STL File](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Original%20OWL/Camera%20mount.stl) |
+| Enclosure Plug | 1 | [STL File](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Original%20OWL/Enclosure%20plug.stl) |
+| **Computing** | | |
+| Raspberry Pi 5 4GB (or Pi 4 or 3B+) | 1 | [Link](https://core-electronics.com.au/raspberry-pi-5-model-b-4gb.html) |
+| *Green-on-Green ONLY - Google Coral USB Accelerator | 1 | [Link](https://coral.ai/products/accelerator) |
+| 64GB SD Card (min. 16 GB) | 1 | [Link](https://core-electronics.com.au/extreme-sd-microsd-memory-card-64gb-class-10-adapter-included.html) |
+| **Camera** (choose one) | | |
+| RECOMMENDED: Raspberry Pi Global Shutter Camera | 1 | [Link](https://core-electronics.com.au/raspberry-pi-global-shutter-camera.html) |
+| CCTV 6mm Wide Angle Lens | 1 (GS or HQ only) | [Link](https://core-electronics.com.au/raspberry-pi-6mm-wide-angle-lens.html) |
+| SUPPORTED: Raspberry Pi 12MP HQ Camera | 1 | [Link](https://core-electronics.com.au/raspberry-pi-hq-camera.html) |
+| SUPPORTED: Raspberry Pi Camera Module 3 | 1 | [Link](https://core-electronics.com.au/raspberry-pi-camera-3.html) |
+| SUPPORTED: Raspberry Pi V2 Camera (NOT RECOMMENDED) | 1 | [Link](https://core-electronics.com.au/raspberry-pi-camera-board-v2-8-megapixels-38552.html) |
+| ⚠️NOTE⚠️ If you use the RPi 5, make sure you have the right camera cable | 1 | [Link](https://core-electronics.com.au/raspberry-pi-camera-fpc-adapter-cable-200mm.html) |
+| **Power** | | |
+| 5V 5A Step Down Voltage Regulator | 1 | [Link](https://core-electronics.com.au/pololu-5v-5a-step-down-voltage-regulator-d24v50f5.html) |
+| 4 Channel, 12V Relay Control Board | 1 | [Link](https://www.jaycar.com.au/arduino-compatible-4-channel-12v-relay-module/p/XC4440?gclid=Cj0KCQjwvYSEBhDjARIsAJMn0ljQf_l5tRY0D4UyDRlaNBFV6-XAj_UGQzC029d-wiwoCyD6Rzy7x2MaAinhEALw_wcB) |
+| M205 Panel Mount Fuse Holder | 1 | [Link](https://www.jaycar.com.au/round-10a-240v-m205-panel-mount-fuse-holder/p/SZ2028?pos=17&queryId=11c21fd77c75a11725bd0f093a0fc862&sort=relevance) |
+| Jumper Wire | 1 | [Link](https://core-electronics.com.au/solderless-breadboard-jumper-cable-wires-female-female-40-pieces.html) |
+| WAGO 2-way Terminal Block | 2 | [Link](https://au.rs-online.com/web/p/splice-connectors/8837544/) |
+| Bulgin Connector - Panel Mount | 1 | [Link](https://au.rs-online.com/web/p/industrial-circular-connectors/8068625/) |
+| Bulgin Connector - Plug | 1 | [Link](https://au.rs-online.com/web/p/industrial-circular-connectors/8068565/) |
+| Micro USB to USB-C adaptor | 1 | [Link](https://core-electronics.com.au/usb-micro-b-to-usb-c-adapter-black.html) |
+| Micro USB Cable | 1 | [Link](https://core-electronics.com.au/micro-usb-cable.html) |
+| **Miscellaneous** | | |
+| 12V Chrome LED | 2 | [Link](https://www.jaycar.com.au/12v-mini-chrome-bezel-red/p/SL2644) |
+| 3 - 16V Piezo Buzzer | 1 | [Link](https://www.jaycar.com.au/mini-piezo-buzzer-3-16v/p/AB3462?pos=8&queryId=404751ef55b1d6b8adef8b031d16576f&sort=relevance) |
+| Brass Standoffs - M2/3/4 | Kit | [Link](https://www.amazon.com/Hilitchi-360pcs-Female-Standoff-Assortment/dp/B013ZWM1F6/ref=sr_1_5?dchild=1&keywords=standoff+kit&qid=1623697572&sr=8-5) |
+| M3 Bolts/Nuts | 4 each or Kit | [Link](https://www.amazon.com/DYWISHKEY-Pieces-Stainless-Socket-Assortment/dp/B07VNDFYNQ/ref=sr_1_4?crid=2X7QROKBF9F4D&dchild=1&keywords=m3+hex+bolt&qid=1623697718&sprefix=M3+hex%2Caps%2C193&sr=8-4) |
+| Wire - 20AWG (red/black/green/blue/yellow/white) | 1 roll each | [Link](https://www.amazon.com/Electronics-different-Insulated-Temperature-Resistance/dp/B07G2GLKMP/ref=sr_1_1_sspa?dchild=1&keywords=20+awg+wire&qid=1623697639&sr=8-1-spons&psc=1&spLa=ZW5jcnlwdGVkUXVhbGlmaWVyPUEyMUNVM1BBQUNKSFNBJmVuY3J5cHRlZElkPUEwNjQ4MTQ5M0dRTE9ZR0MzUFE5VyZlbmNyeXB0ZWRBZElkPUExMDMwNTIwODM5OVVBOTFNRjdSJndpZGdldE5hbWU9c3BfYXRmJmFjdGlvbj1jbGlja1JlZGlyZWN0JmRvTm90TG9nQ2xpY2s9dHJ1ZQ==) |
+| *Optional* | | |
+| Real-time clock module | 1 | [Link](https://core-electronics.com.au/adafruit-pirtc-pcf8523-real-time-clock-for-raspberry-pi.html) |
-| **Component** | **Quantity** | **Link** |
-| ------------- | ------------- | ------------- |
-| **Enclosure** | | |
-| Main Case (single Bulgin connector) | 1 | [STL File](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Enclosure%20-%20single%20connector.stl) |
-| *Main Case (cable glands)* | 1 | [STL File](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Enclosure%20-%20cable%20gland.stl) |
-| Main Cover | 1 | [STL File](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Enclosure%20-%20cover.stl) |
-| Raspberry Pi Mount | 1 | [STL File](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Raspberry%20Pi%20mount.stl) |
-| Relay Control Board Mount | 1 | [STL File](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Relay%20control%20board%20mount.stl) |
-| Voltage Regulator Mount | 1 | [STL File](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Voltage%20regulator%20mount.stl) |
-| Camera Mount | 1 | [STL File](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Camera%20mount.stl) |
-| Enclosure Plug | 1 | [STL File](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Enclosure%20plug.stl) |
-| **Computing** | | |
-| Raspberry Pi 4 8GB | 1 | [Link](https://core-electronics.com.au/raspberry-pi-4-model-b-8gb.html) |
-| 64GB SD Card | 1 | [Link](https://core-electronics.com.au/extreme-sd-microsd-memory-card-64gb-class-10-adapter-included.html) |
-| **Camera** | | |
-| Raspberry Pi HQ Camera | 1 | [Link](https://core-electronics.com.au/raspberry-pi-hq-camera.html) |
-| CCTV 6mm Wide Angle Lens | 1 | [Link](https://core-electronics.com.au/raspberry-pi-6mm-wide-angle-lens.html) |
-| **Power** | | |
-| 5V 5A Step Down Voltage Regulator | 1 | [Link](https://core-electronics.com.au/pololu-5v-5a-step-down-voltage-regulator-d24v50f5.html) |
-| 4 Channel, 12V Relay Control Board | 1 | [Link](https://www.jaycar.com.au/arduino-compatible-4-channel-12v-relay-module/p/XC4440?gclid=Cj0KCQjwvYSEBhDjARIsAJMn0ljQf_l5tRY0D4UyDRlaNBFV6-XAj_UGQzC029d-wiwoCyD6Rzy7x2MaAinhEALw_wcB) |
-| M205 Panel Mount Fuse Holder | 1 | [Link](https://www.jaycar.com.au/round-10a-240v-m205-panel-mount-fuse-holder/p/SZ2028?pos=17&queryId=11c21fd77c75a11725bd0f093a0fc862&sort=relevance) |
-| Jumper Wire | 1 | [Link](https://core-electronics.com.au/solderless-breadboard-jumper-cable-wires-female-female-40-pieces.html) |
-| WAGO 2-way Terminal Block | 2 | [Link](https://au.rs-online.com/web/p/splice-connectors/8837544/) |
-| Bulgin Connector - Panel Mount | 1 | [Link](https://au.rs-online.com/web/p/industrial-circular-connectors/8068625/) |
-| Bulgin Connector - Plug | 1 | [Link](https://au.rs-online.com/web/p/industrial-circular-connectors/8068565/) |
-| Micro USB to USB-C adaptor | 1 | [Link](https://core-electronics.com.au/usb-micro-b-to-usb-c-adapter-black.html) |
-| Micro USB Cable | 1 | [Link](https://core-electronics.com.au/micro-usb-cable.html) |
-| **Miscellaneous** | | |
-| 12V Chrome LED | 2 | [Link](https://www.jaycar.com.au/12v-mini-chrome-bezel-red/p/SL2644) |
-| 3 - 16V Piezo Buzzer | 1 | [Link](https://www.jaycar.com.au/mini-piezo-buzzer-3-16v/p/AB3462?pos=8&queryId=404751ef55b1d6b8adef8b031d16576f&sort=relevance) |
-| Brass Standoffs - M2/3/4 | Kit | [Link](https://www.amazon.com/Hilitchi-360pcs-Female-Standoff-Assortment/dp/B013ZWM1F6/ref=sr_1_5?dchild=1&keywords=standoff+kit&qid=1623697572&sr=8-5) |
-| M3 Bolts/Nuts | 4 each or Kit | [Link](https://www.amazon.com/DYWISHKEY-Pieces-Stainless-Socket-Assortment/dp/B07VNDFYNQ/ref=sr_1_4?crid=2X7QROKBF9F4D&dchild=1&keywords=m3+hex+bolt&qid=1623697718&sprefix=M3+hex%2Caps%2C193&sr=8-4) |
-| Wire - 20AWG (red/black/green/blue/yellow/white) | 1 roll each | [Link](https://www.amazon.com/Electronics-different-Insulated-Temperature-Resistance/dp/B07G2GLKMP/ref=sr_1_1_sspa?dchild=1&keywords=20+awg+wire&qid=1623697639&sr=8-1-spons&psc=1&spLa=ZW5jcnlwdGVkUXVhbGlmaWVyPUEyMUNVM1BBQUNKSFNBJmVuY3J5cHRlZElkPUEwNjQ4MTQ5M0dRTE9ZR0MzUFE5VyZlbmNyeXB0ZWRBZElkPUExMDMwNTIwODM5OVVBOTFNRjdSJndpZGdldE5hbWU9c3BfYXRmJmFjdGlvbj1jbGlja1JlZGlyZWN0JmRvTm90TG9nQ2xpY2s9dHJ1ZQ==) |
-| *Optional* | | |
-| Real-time clock module | 1 | [Link](https://core-electronics.com.au/adafruit-pirtc-pcf8523-real-time-clock-for-raspberry-pi.html) |
-## Hardware Assembly
-All components listed above are relatively "plug and play" with minimal soldering or complex electronics required. Follow these instructions carefully and triple check your connections before powering anything on to avoid losing the [magic smoke](https://en.wikipedia.org/wiki/Magic_smoke) and potentially a few hundred dollars. Never make changes to the wiring on the detection unit while it is connected to 12V and always remain within the safe operating voltages of any component.
-
-Complete guide to hardware assembly
+ Compact OWL - Hardware List
-Before starting, have a look at the complete wiring diagram below to see how everything fits together. The LEDs, fuse and Bulgin connector are all mounted on the rear of the OWL unit, rather than where they are located in the diagram. If you prefer not to use or can't access a Bulgin connector, there is a separate 3D model design that uses cable glands instead.
+
+### Compact OWL
+The new OWL design is more compact, inside either an extruded aluminium enclosure or 3D printed housing. It offers
+improved water and dust resistance, plus ease of assembly and longevity. This design is recommended for production use.
+
+The parts list is substantially reduced:
+
+| **Component** | **Quantity** | **Link** |
+|----------------------------------------------------------------------|-------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| **Enclosure** | | |
+| **OFFICIAL OWL ENCLOSURE** - aluminium | 1 | TBD |
+| Extrusion - 3D printed | 1 | [STL File](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Compact%20OWL/Main%20Body.stl) |
+| Front plate | 1 | [STL File](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Compact%20OWL/Frontplate.stl) |
+| Tray | 1 | [STL File](https://github.com/geezacoleman/OpenWeedLocator/blob/picamera2/3D%20Models/Compact%20OWL/Tray.stl) |
+| Back plate - Amphenol, Adafruit RJ45 | 1* | [STL File](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Compact%20OWL/Backplate%20-%20Receptacle%20and%20Ethernet.stl) |
+| Back plate - Amphenol only | 1* | [STL File](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Compact%20OWL/Backplate%20-%20Receptacle%20Only.stl) |
+| Back plate - 16 mm cable gland | 1* | [STL File](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Compact%20OWL/Backplate%20-%20Gland.stl) |
+| Lens mount | 1 | [STL File](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Compact%20OWL/Lens%20Mount.stl) |
+| Camera mount | 1* | [STL File](https://github.com/geezacoleman/OpenWeedLocator/blob/picamera2/3D%20Models/Compact%20OWL/Camera%20Mount.stl) |
+| **Computing** | | |
+| Raspberry Pi 5 4GB (or Pi 4 or 3B+) | 1 | [Link](https://core-electronics.com.au/raspberry-pi-5-model-b-4gb.html) |
+| *Green-on-Green ONLY - Google Coral USB Accelerator | 1 | [Link](https://coral.ai/products/accelerator) |
+| 64GB SD Card (min. 16 GB) | 1 | [Link](https://core-electronics.com.au/extreme-sd-microsd-memory-card-64gb-class-10-adapter-included.html) |
+| **Camera** (choose one) | | |
+| RECOMMENDED: Raspberry Pi Global Shutter Camera | 1 | [Link](https://core-electronics.com.au/raspberry-pi-global-shutter-camera.html) |
+| CCTV 6mm Wide Angle Lens | 1 (GS or HQ only) | [Link](https://core-electronics.com.au/raspberry-pi-6mm-wide-angle-lens.html) |
+| SUPPORTED: Raspberry Pi 12MP HQ Camera | 1 | [Link](https://core-electronics.com.au/raspberry-pi-hq-camera.html) |
+| SUPPORTED: Raspberry Pi Camera Module 3 | 1 | [Link](https://core-electronics.com.au/raspberry-pi-camera-3.html) |
+| SUPPORTED: Raspberry Pi V2 Camera (NOT RECOMMENDED) | 1 | [Link](https://core-electronics.com.au/raspberry-pi-camera-board-v2-8-megapixels-38552.html) |
+| ⚠️NOTE⚠️ If you use the RPi 5, make sure you have the right camera cable | 1 | [Link](https://core-electronics.com.au/raspberry-pi-camera-fpc-adapter-cable-200mm.html) |
+| **Power Management** * items only needed in place of OWL driver board | 1 | |
+| **OFFICIAL OWL DRIVER BOARD** (incl. power mgmt, relay control) | 1 | TBD |
+| * 5V 5A Step Down Voltage Regulator | 1 | [Link](https://core-electronics.com.au/pololu-5v-5a-step-down-voltage-regulator-d24v50f5.html) |
+| * 4 Channel, Relay Control Board HAT | 1 | [Link](https://core-electronics.com.au/pirelay-v2-relay-board-for-raspberry-pi-1.html?gad_source=1&gclid=Cj0KCQjw_qexBhCoARIsAFgBlev42xD_VLsmZHCLmIPB-NCCMRCGtKRPbH7WV2ddw4oucobn-XOUpLkaArl5EALw_wcB) |
+| * Jumper Wire | 1 | [Link](https://core-electronics.com.au/solderless-breadboard-jumper-cable-wires-female-female-40-pieces.html) |
+| Amphenol Fathomlock Connector - 6 pin connector (FLS6BS10N3W3P03) | 1 | [Link](https://au.mouser.com/ProductDetail/Amphenol-SINE-Systems/FLS6BS10N3W3P03?qs=ulEaXIWI0c%2FMtNeYzYmViA%3D%3D) |
+| Amphenol Fathomlock Connector - 6 pin plug (FLS710N3W3S03) | 1 | [Link](https://au.mouser.com/ProductDetail/Amphenol-SINE-Systems/FLS710N3W3S03?qs=ulEaXIWI0c8tWp%252BkBCr3Ag%3D%3D) |
+| Adafruit RJ45 Cable Gland | 1 | [Link](https://au.mouser.com/ProductDetail/Adafruit/827?qs=GURawfaeGuA%2FhbkGNTwr3g%3D%3D) |
+| 16mm Cable Gland | 1 | [Link](https://au.mouser.com/ProductDetail/Davies-Molding/GC1000-B?qs=xhbEVWpZdWd7C8HYv4mDiQ%3D%3D) |
+| **Miscellaneous** | | |
+| 3 - 16V Piezo Buzzer (optional) | 1 | [Link](https://www.jaycar.com.au/mini-piezo-buzzer-3-16v/p/AB3462?pos=8&queryId=404751ef55b1d6b8adef8b031d16576f&sort=relevance) |
+| Brass Standoffs - M2/3/4 (required for HAT/driver board) | Kit | [Link](https://www.amazon.com/Hilitchi-360pcs-Female-Standoff-Assortment/dp/B013ZWM1F6/ref=sr_1_5?dchild=1&keywords=standoff+kit&qid=1623697572&sr=8-5) |
+| Wire - 20AWG (red/black/green/blue/yellow/white) | 1 roll each | [Link](https://www.amazon.com/Electronics-different-Insulated-Temperature-Resistance/dp/B07G2GLKMP/ref=sr_1_1_sspa?dchild=1&keywords=20+awg+wire&qid=1623697639&sr=8-1-spons&psc=1&spLa=ZW5jcnlwdGVkUXVhbGlmaWVyPUEyMUNVM1BBQUNKSFNBJmVuY3J5cHRlZElkPUEwNjQ4MTQ5M0dRTE9ZR0MzUFE5VyZlbmNyeXB0ZWRBZElkPUExMDMwNTIwODM5OVVBOTFNRjdSJndpZGdldE5hbWU9c3BfYXRmJmFjdGlvbj1jbGlja1JlZGlyZWN0JmRvTm90TG9nQ2xpY2s9dHJ1ZQ==) |
+
+
+
+## Hardware Assembly
+Separate guides are provided for the Original OWL assembly and the Compact OWL.
### Required tools
* Wire strippers
* Wire cutters
-* Soldering iron/solder
+* Pliers
+* Soldering iron/solder (only for Original OWL)
+
+
+ Original OWL - Hardware Assembly
+
+
+>⚠️**NOTE**⚠️ All components listed above are relatively "plug and play" with minimal soldering or complex electronics required.
+Follow these instructions carefully and triple check your connections before powering anything on to avoid losing
+the [magic smoke](https://en.wikipedia.org/wiki/Magic_smoke) and potentially a few hundred dollars. Never make changes
+to the wiring on the detection unit while it is connected to 12V and always remain within the safe operating voltages of
+any component.
+
+## Original OWL - Hardware Assembly
+
+A [video guide](https://www.youtube.com/watch?v=vZqNKogzz8k) is available for the Original OWL assembly.
+
+Before starting, have a look at the complete wiring diagram below to see how everything fits together. The LEDs, fuse and Bulgin connector are all mounted on the rear of the OWL unit, rather than where they are located in the diagram. If you prefer not to use or can't access a Bulgin connector, there is a separate 3D model design that uses cable glands instead.

### Step 1 - enclosure and mounts
-Assembling the components for an OWL unit requires the enclosure and mounts as a minimum. These can be 3D printed on your own printer or printed and delivered from one of the many online stores that offer a 3D printing service. Alternatively, you could create your own enclosure using a plastic electrical box and cutting holes in it, if that's easier. We'll be assuming you have printed out the enclosure and associated parts for the rest of the guide, but please share your finished designs however they turn out!
-The first few steps don't require the enclosure so you can make a start right away, but while you're working on getting that assembled, make sure you have the pieces printing, they'll be used from Step 4. For a complete device, you'll need: 1 x base, 1 x cover, 1 x RPi mount, 1 x relay mount, 1 x regulator mount, 1 x camera mount and 1 x plug.
+Assembling the components for an OWL unit requires the enclosure and mounts as a minimum. These can be 3D printed on
+your own printer or printed and delivered from one of the many online stores that offer a 3D printing service.
+Alternatively, you could create your own enclosure using a plastic electrical box and cutting holes in it, if that's
+easier. We'll be assuming you have printed out the enclosure and associated parts for the rest of the guide, but please
+share your finished designs however they turn out!
+
+The first few steps don't require the enclosure so you can make a start right away, but while you're working on getting
+that assembled, make sure you have the pieces printing, they'll be used from Step 4. For a complete device, you'll need:
+1 x base, 1 x cover, 1 x RPi mount, 1 x relay mount, 1 x regulator mount, 1 x camera mount and 1 x plug.
### Step 2 - soldering
+
There are only a few components that need soldering, including the fuse and voltage regulator:
+
* Soldering of voltage regulator pins
* Soldering of 12V input wires to voltage regulator pins
* Soldering of 5V output wires to voltage regulator pins (micro USB cable)
@@ -148,33 +344,50 @@ There are only a few components that need soldering, including the fuse and volt
Carefully check which pins on the voltage regulator correspond to 12V in, GND in, 5V out and GND out prior to soldering.
-To solder the Micro USB cable to the voltage regulator output, you'll need to cut off the USB A end so you are left with approximately 10cm of cable. Using the wire strippers or a sharp box cutter/knife, remove the rubber sheath around the wires. If you have a data + charging cable you should see red, green, white and black wires. The charging only cables will likely only have the red and black wires. Isolate the red (+5V) and black (GND) wires and strip approximately 5mm off the end. Solder the red wire to the positive output on the voltage regulator and black wire to the GND pin. Once you have finished, it should look like the first panel in the figure below.
+To solder the Micro USB cable to the voltage regulator output, you'll need to cut off the USB A end so you are left with
+approximately 10cm of cable. Using the wire strippers or a sharp box cutter/knife, remove the rubber sheath around the
+wires. If you have a data + charging cable you should see red, green, white and black wires. The charging only cables
+will likely only have the red and black wires. Isolate the red (+5V) and black (GND) wires and strip approximately 5mm
+off the end. Solder the red wire to the positive output on the voltage regulator and black wire to the GND pin. Once you
+have finished, it should look like the first panel in the figure below.
-**NOTE**: Soldering can burn you and generates potentially hazardous smoke! Use appropriate care, fume extractors and PPE to avoid any injury. If you're new to soldering, read through [this guide](https://www.makerspaces.com/how-to-solder/), which explains in more detail how to perfect your skills and solder safely.
+>⚠️**NOTE**⚠️ Soldering can burn you and generates potentially hazardous smoke! Use appropriate care, fume extractors and
+PPE to avoid any injury. If you're new to soldering, read
+through [this guide](https://www.makerspaces.com/how-to-solder/), which explains in more detail how to perfect your
+skills and solder safely.
-**NOTE**: When soldering, it's best to cover the exposed terminals with glue lined heat shrink to reduce the risk of electrical short circuits.
+>⚠️**NOTE**⚠️ When soldering, it's best to cover the exposed terminals with glue lined heat shrink to reduce the risk of
+electrical short circuits.
Voltage regulator | Voltage regulator pins | Fuse
:-------------: | :-------------: | :-------------:
 | | 
-Once the two red wires are soldered to the fuse, the fuse can be mounted on the rear panel of the OWL base. One wire will be connected to the Bulgin plug (next step) and the other to the Wago 2-way block.
+Once the two red wires are soldered to the fuse, the fuse can be mounted on the rear panel of the OWL base. One wire
+will be connected to the Bulgin plug (next step) and the other to the Wago 2-way block.
-For neater wiring you can also solder jumpers between all the normally open (NO) pins on the base of the relay board, but this is optional. If you don't solder these connections, make sure you connect wire using the screw terminals instead. Photos of both are provided below.
+For neater wiring you can also solder jumpers between all the normally open (NO) pins on the base of the relay board,
+but this is optional. If you don't solder these connections, make sure you connect wire using the screw terminals
+instead. Photos of both are provided below.
Soldered | Screw terminals
:-------------: | :-------------:
 | 
-The other wires requiring soldering are joins between the buzzer and jumper wires for easy connection to the GPIO pins and from the LEDs to the power in/jumper wires.
+The other wires requiring soldering are joins between the buzzer and jumper wires for easy connection to the GPIO pins
+and from the LEDs to the power in/jumper wires.
-### Step 3 - wiring up Bulgin connector
-Next we'll need to wire the output relay control and input 12V wires to the Bulgin panel mount connector. Fortunately all pins are labelled, so follow the wire number table below. This will need to be repeated for the Bulgin plug as well, which will connect your solenoids or other devices to the relay control board.
+### Step 3 - wiring up Bulgin connector
+
+Next we'll need to wire the output relay control and input 12V wires to the Bulgin panel mount connector. Fortunately
+all pins are labelled, so follow the wire number table below. This will need to be repeated for the Bulgin plug as well,
+which will connect your solenoids or other devices to the relay control board.
The process is:
+
1. Connect all wires to Bulgin connector using the screw terminals
2. Mount the connector to the rear panel
-3. Leave at least 10cm of wire so it can be connected to the relay board and other connections later.
+3. Leave at least 10cm of wire so it can be connected to the relay board and other connections later.
Bulgin terminal number | Wire connection
:-------------: | :-------------:
@@ -185,66 +398,103 @@ Bulgin terminal number | Wire connection
5 | Red 12VDC - connects to fuse wire already soldered. Make sure wire is the right length when mounted.
6 | Black GND - connects to Wago 2-way terminal
-**NOTE**: Skip this step if you're using cable glands.
+>⚠️**NOTE**⚠️ Skip this step if you're using cable glands.
Once all the wires have been connected you can now mount the Bulgin connector to the OWL base.
### Step 4 - mounting the relay control board and voltage regulator
-Attach the relay control board to the 3D printed relay control board mount using 2.5 mm standoffs. Attach the voltage regulator to the 3D printed voltage regulator mount with 2 mm standoffs. The mounted voltage regulator can then be mounted to one corner of the relay control board. The relay board and voltage regulator can then be installed in the raised slots in the OWL base.
-**NOTE**: Use **2.5 mm** standoffs for mounting the relay control board to its base. Use **2 mm** standoffs to mount the voltage regulator to its base.
+Attach the relay control board to the 3D printed relay control board mount using 2.5 mm standoffs. Attach the voltage
+regulator to the 3D printed voltage regulator mount with 2 mm standoffs. The mounted voltage regulator can then be
+mounted to one corner of the relay control board. The relay board and voltage regulator can then be installed in the
+raised slots in the OWL base.
+
+>⚠️**NOTE**⚠️ Use **2.5 mm** standoffs for mounting the relay control board to its base. Use **2 mm** standoffs to mount the
+voltage regulator to its base.

### Step 5 - wiring the relay control board, voltage regulator, Wago 2-way blocks and Bulgin connector
-Connect the relay control board to the Bulgin connector using the table in step 3 as a guide.
-**NOTE**: Some relay control boards such as [this](https://www.amazon.com/ELEGOO-Channel-Optocoupler-Arduino-Raspberry/dp/B01HEQF5HU/ref=asc_df_B01HEQF5HU/?tag=hyprod-20&linkCode=df0&hvadid=198076677096&hvpos=&hvnetw=g&hvrand=5997956897740931812&hvpone=&hvptwo=&hvqmt=&hvdev=c&hvdvcmdl=&hvlocint=&hvlocphy=9027902&hvtargid=pla-350609711896&psc=1) on Amazon are ACTIVE on LOW. This means that the signal provided by the Raspberry Pi (a higher voltage) to activate a relay will instead turn the relay off. While this can be changed in the code, please consider purchasing HIGH level trigger (e.g [the board specified in the parts list](https://www.jaycar.com.au/arduino-compatible-4-channel-12v-relay-module/p/XC4440)) or adjustable trigger (e.g. [this board](https://www.amazon.com/DZS-Elec-Optocoupler-Isolation-Triggered/dp/B07BDJJTLZ/ref=asc_df_B07BDJJTLZ/?tag=hyprod-20&linkCode=df0&hvadid=241912880102&hvpos=&hvnetw=g&hvrand=5997956897740931812&hvpone=&hvptwo=&hvqmt=&hvdev=c&hvdvcmdl=&hvlocint=&hvlocphy=9027902&hvtargid=pla-438273746158&psc=1)).
+Connect the relay control board to the Bulgin connector using the table in step 3 as a guide.
-Next, connect red and black jumper wires to the VCC and GND header pins on the relay control board. Now choose one Wago block to be a 12V positive block and the second to be the negative or ground. To the positive block, connect the 12 V wire from the fuse (12V input from source), the 12 V input to the voltage regulator, the 12 V solenoid line from the relay board and the VCC line from the relay board to one of the two WAGO terminal blocks, twisting the wires together if necessary. Repeat with the second, negative WAGO terminal block, connecting the input ground line from the Bulgin connector, ground line from the voltage regulator and the GND black wire from the relay board.
+>⚠️**NOTE**⚠️ Some relay control boards such
+as [this](https://www.amazon.com/ELEGOO-Channel-Optocoupler-Arduino-Raspberry/dp/B01HEQF5HU/ref=asc_df_B01HEQF5HU/?tag=hyprod-20&linkCode=df0&hvadid=198076677096&hvpos=&hvnetw=g&hvrand=5997956897740931812&hvpone=&hvptwo=&hvqmt=&hvdev=c&hvdvcmdl=&hvlocint=&hvlocphy=9027902&hvtargid=pla-350609711896&psc=1)
+on Amazon are ACTIVE on LOW. This means that the signal provided by the Raspberry Pi (a higher voltage) to activate a
+relay will instead turn the relay off. While this can be changed in the code, please consider purchasing HIGH level
+trigger (
+e.g [the board specified in the parts list](https://www.jaycar.com.au/arduino-compatible-4-channel-12v-relay-module/p/XC4440))
+or adjustable trigger (
+e.g. [this board](https://www.amazon.com/DZS-Elec-Optocoupler-Isolation-Triggered/dp/B07BDJJTLZ/ref=asc_df_B07BDJJTLZ/?tag=hyprod-20&linkCode=df0&hvadid=241912880102&hvpos=&hvnetw=g&hvrand=5997956897740931812&hvpone=&hvptwo=&hvqmt=&hvdev=c&hvdvcmdl=&hvlocint=&hvlocphy=9027902&hvtargid=pla-438273746158&psc=1)).
+
+Next, connect red and black jumper wires to the VCC and GND header pins on the relay control board. Now choose one Wago
+block to be a 12V positive block and the second to be the negative or ground. To the positive block, connect the 12 V
+wire from the fuse (12V input from source), the 12 V input to the voltage regulator, the 12 V solenoid line from the
+relay board and the VCC line from the relay board to one of the two WAGO terminal blocks, twisting the wires together if
+necessary. Repeat with the second, negative WAGO terminal block, connecting the input ground line from the Bulgin
+connector, ground line from the voltage regulator and the GND black wire from the relay board.
Installed relay board | Relay board wiring diagram | Relay board wiring
:-------------: | :-------------: | :-------------:
 |  |  |
### Step 6 - mounting Raspberry Pi and connecting power
-Attach the Raspberry Pi to the 3D printed mount using 2.5 mm standoffs. Install in the raised slots in the OWL base. Connect to micro USB power from the voltage regulator, using a micro USB to USB-C adaptor. Alternatively, the Raspberry Pi can be powered over the GPIO, however, this has not yet been implemented.
-Raspberry Pi mount | Raspberry Pi in OWL base
-:-------------: | :-------------:
- | 
+Attach the Raspberry Pi to the 3D printed mount using 2.5 mm standoffs. Install in the raised slots in the OWL base.
+Connect to micro USB power from the voltage regulator, using a micro USB to USB-C adaptor. Alternatively, the Raspberry
+Pi can be powered over the GPIO, however, this has not yet been implemented.
-### Step 7 - connecting GPIO pins
-Connect the Raspberry Pi GPIO to the relay control board header pins, using the table below and the wiring diagram above as a guide:
+| Raspberry Pi mount | Raspberry Pi in OWL base |
+|:----------------------------------------------------------------------------------------------------:|:-------------------------------------------------------------------------------------------------------------------:|
+|  |  |
-The GPIO pins on the Raspberry Pi are not clearly labelled, so use this guide to help. Be careful when connecting these pins as incorrect wiring can shortcircuit/damage your Pi.
-
+### Step 7 - connecting GPIO pins
+Connect the Raspberry Pi GPIO to the relay control board header pins, using the table below and the wiring diagram above
+as a guide:
+The GPIO pins on the Raspberry Pi are not clearly labelled, so use this guide to help. Be careful when connecting these
+pins as incorrect wiring can shortcircuit/damage your Pi.
+
-RPi GPIO pin | Relay header pin
-:-------------: | :-------------:
-13 | IN1
-14 | COM
-15 | IN2
-16 | IN3
-18 | IN4
+| RPi GPIO pin | Relay header pin |
+|:------------:|:----------------:|
+| 13 | IN1 |
+| 14 | COM |
+| 15 | IN2 |
+| 16 | IN3 |
+| 18 | IN4 |
-Raspberry Pi GPIO pins | Relay control board header pins
-:-------------: | :-------------:
- | 
+| Raspberry Pi GPIO pins | Relay control board header pins |
+|:-------------------------------------------------------------------------------------------------------:|:-----------------------------------------------------------------------------------------------------------:|
+|  |  |

### Step 8 - mounting and connecting camera
-Connect one end of the CSI ribbon cable to the camera. We provide a mounting plate that can be used with both the HQ or V2 cameras, however, we recommend the use of the HQ camera for improved image clarity. Attach the HQ camera to the 3D printed mount using 2.5 mm standoffs (or 2 mm standoffs if using the V2 camera). Ensuring that the CSI cable port on the camera is directed towards the Raspberry Pi, mount the camera inside the OWL case using four M3 standoffs (50 mm long for HQ camera; 20 mm long for V2 camera). Connect the other end of the CSI cable to the Raspberry Pi CSI camera port.
-**NOTE** the HQ lens comes with a C-CS mount adapter which needs to be removed before fitting to the camera sensor base. The image won't focus unless the adapter is removed.
+Connect one end of the CSI ribbon cable to the camera. We provide a mounting plate that can be used with both the HQ, Global Shutter or
+V2 cameras, however, we recommend the use of the HQ camera for improved image clarity. Attach the HQ camera to the 3D
+printed mount using 2.5 mm standoffs (or 2 mm standoffs if using the V2 camera). Ensuring that the CSI cable port on the
+camera is directed towards the Raspberry Pi, mount the camera inside the OWL case using four M3 standoffs (50 mm long
+for HQ camera; 20 mm long for V2 camera). Connect the other end of the CSI cable to the Raspberry Pi CSI camera port.
+Before connecting the lens, please be aware the HQ camera comes with fitted a C-CS mount adapter which needs to be
+removed before fitting the 6mm lens. The image won't focus unless the adapter is removed. More information is available
+below and in the [HQ Camera Datasheet](https://datasheets.raspberrypi.com/hq-camera/cs-mount-lens-guide.pdf)
+
+How to remove the C-CS mount adapter:
+
+HQ camera C-CS mount adapter | Camera and adapter separated | Lens fitted without adapter
+:-------------: | :-------------: | :-------------:
+||
+
+Mounting the HQ camera to the 3D printed mount:
HQ camera and mount | HQ camera mounted in case
:-------------: | :-------------:
 | 
+Mounting the V2 camera to the 3D printed mount:
V2 camera and mount | V2 camera mounted in case | Raspberry Pi camera port
:-------------: | :-------------: | :-------------:
 |  | 
@@ -252,143 +502,491 @@ V2 camera and mount | V2 camera mounted in case | Raspberry Pi camera port
The HQ lens will need to be focused, details below, once the software is correctly set up.
### Step 9 - adding buzzer and LEDs
-Mount the buzzer inside the OWL base using double sided mounting tape and connect the 5 V and ground wires to Raspberry Pi GPIO pins 7 and 9, respectively.
-For simplicity we have used two 12V LEDs (which are just normal LEDs with a current limiting resistor included) for both the 5V TX/GND connection for Raspberry Pi status indication and also the 12V power connection. While 12 V will work fine on both, the 5 V connection will be dimmer. If you want to use a non-prepackaged, 3 mm LED for the 5V connection, you should solder a current limiting resistor to the LED to prevent damage to either the LED or the Rasperry Pi as described [here](https://howchoo.com/g/ytzjyzy4m2e/build-a-simple-raspberry-pi-led-power-status-indicator). Install the 5 V LED inside the OWL base and connect the 5V and ground wire to GPIO pins 8 (TX pin) and 20 (GND pin), respectively. Install the 12 V LED inside the OWL base and connect the 12 V and GND wires to their respective WAGO terminal blocks.
+Mount the buzzer inside the OWL base using double sided mounting tape and connect the 5 V and ground wires to Raspberry
+Pi GPIO pins 7 and 9, respectively.
+
+For simplicity we have used two 12V LEDs (which are just normal LEDs with a current limiting resistor included) for both
+the 5V TX/GND connection for Raspberry Pi status indication and also the 12V power connection. While 12 V will work fine
+on both, the 5 V connection will be dimmer. If you want to use a non-prepackaged, 3 mm LED for the 5V connection, you
+should solder a current limiting resistor to the LED to prevent damage to either the LED or the Rasperry Pi as
+described [here](https://howchoo.com/g/ytzjyzy4m2e/build-a-simple-raspberry-pi-led-power-status-indicator). Install the
+5 V LED inside the OWL base and connect the 5V and ground wire to GPIO pins 8 (TX pin) and 20 (GND pin), respectively.
+Install the 12 V LED inside the OWL base and connect the 12 V and GND wires to their respective WAGO terminal blocks.
Buzzer location | LEDs in OWL base | GPIO pins
:-------------: | :-------------: | :-------------:
 |  | 
### OPTIONAL STEP - adding real time clock module
-Although optional, we recommend that you use a real time clock (RTC) module with the OWL system. This will enable the Raspberry Pi to hold the correct time when disconnected from power and the internet, and will be useful for debugging errors if they arise. The RTC uses a CR1220 button cell battery and sits on top of the Raspberry Pi using GPIO pins 1-6.
+
+Although optional, we recommend that you use a real time clock (RTC) module with the OWL system. This will enable the
+Raspberry Pi to hold the correct time when disconnected from power and the internet, and will be useful for debugging
+errors if they arise. The RTC uses a CR1220 button cell battery and sits on top of the Raspberry Pi using GPIO pins 1-6.
PiRTC module | RTC installed on Raspberry Pi
:-------------: | :-------------:
 | 
### Step 10 - connecting mounting hardware and OWL cover
-There are four 6.5 mm holes on the OWL base for mounting to a boom. Prior to installing the OWL cover, decide on a mounting solution suitable to your needs. In the photo below, we used 4 x M6 bolts. The cover of the OWL unit is secured with 4 x M3 nuts and bolts. Place M3 nuts into the slots in the OWL base. This can be fiddly and we suggest using tweezers, as shown below. Place the cover onto the base and secure using M3 bolts.
+
+There are four 6.5 mm holes on the OWL base for mounting to a boom. Prior to installing the OWL cover, decide on a
+mounting solution suitable to your needs. In the photo below, we used 4 x M6 bolts. The cover of the OWL unit is secured
+with 4 x M3 nuts and bolts. Place M3 nuts into the slots in the OWL base. This can be fiddly and we suggest using
+tweezers, as shown below. Place the cover onto the base and secure using M3 bolts.
Mounting hardware | Cover nuts | Completed OWL unit
:-------------: | :-------------: | :-------------:
 |  | 
-### Step 11 - connecting 12V solenoids
-Once you have completed the setup, you now have the opportunity to wire up your own solenoids for spot spraying, targeted tillage, spot flaming or any other targeted weed control you can dream up. To do this, wire the GND wire of your device (it can be any wire if it's a solenoid) to the ground pin on the Bulgin plug (the same wire used for the GND from the 12V power source) and wire the other to one of the blue, green, orange or white wires on pins 1 - 4. A wiring diagram is provided below. The easiest way to wire them together to the same GND wire is to create a six-way harness, where one end is connected to the plug, one of the five other wires to the source power GND and the remaining four to the solenoids or whatever devices you are driving.
-
+
+
+
+ Compact OWL - Hardware Assembly
+
+
+One major benefit of the Compact OWL (with the OWL driver board) is that no soldering is required. The design removes
+much of the wiring, improving ease of assembly and reliability. If you choose a Raspberry Pi Relay HAT instead of the
+OWL driver board, you'll just need to add a voltage regulator and solder that in to provide the 5V @ 5A required for
+the Raspberry Pi 5 or up to 3A required for the older models.
+
+There are two options for the enclosure. The 3D printed enclosure and the official OWL extruded aluminium enclosure. Both share
+a 3D printed tray and the same components. The 3D printed version allows you to make a start without needing to buy bespoke
+components if you have access to a 3D printer.
+
+The 3D model files for the printed enclosure can be downloaded [here]().
+
+The Official OWL Enclosure will be available for purchase through the OWL store soon.
+
+| Compact OWL - Extruded Aluminium Enclosure | Compact OWL - 3D Printed Enclosure |
+|-----------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------|
+|  |  |
+
+>⚠️**NOTE**⚠️ The 3D printed version requires the additional purchase of:
+1. [1.6mm](https://au.rs-online.com/web/p/o-ring-cords/1591478) and [3mm](https://au.rs-online.com/web/p/o-ring-cords/1591490) nitrile rubber o-ring cord
+2. [M2, M3 and M4 threaded inserts](https://www.amazon.com.au/WEZCHUGHAOL-Threaded-Embedment-Printing-Components/dp/B0CN39ZSC2/ref=sr_1_21_sspa?crid=2R258Z1R9XYJQ&dib=eyJ2IjoiMSJ9.YV_8e4yZB5Up2sxjc6yADA7Nnr7U_kpewviCOxQiUAiT6HPGv5rLlXY1PVeDUBAfmO5LAuekzE8VmOU_0V6pDgL1lOmLjEqU8cGrC2bBxPeu3bDe1ZAScHdT6FLAoWi8i-J9F7nz0hj0S_zyow4N92_ZBdySI1CdG651qgCoF7hC5Av5xYcZBqJ41agRh0WjTmNiIGDV9LRODEPy9hAwFm7tM8XzQpL7jXGtycJNoqOVEEck64araKnzphdkqWC0wKWFVRQOyTKw7LM3TBAsXJPsq83qrBvGJ0vNIgayg2Y.gVKTvrTwcC1PdIZc9g_UckOD3B8z063oPKo9M3o9-Sg&dib_tag=se&keywords=embedded+nuts+threaded&qid=1715135215&sprefix=embedded+nuts+threadd%2Caps%2C385&sr=8-21-spons&sp_csd=d2lkZ2V0TmFtZT1zcF9tdGY&psc=1) + M2, M3 and M4 hex head screws
+3. [K&F Concept 37 mm UV lens filter](https://www.amazon.com.au/Concept-18-Layer-Protection-Nanotech-Ultra-Slim/dp/B07NYPCPD5/ref=sr_1_4_sspa?crid=7B57R6GYCCC3&dib=eyJ2IjoiMSJ9.WLMO784g_HVvxPY8RxBi3DdjOJlAw_RRc543yR2qlin4vGGdFusrTxn-OxNr4IQHY_EKtKFwLx9ti6e-ALuUeVuEPGkmCZS7yYe_uisRUN6iDpCOagXAL06Q0aOmh6lWsjS1evk7QMSITdwViuI32n7Ow8KUD6r4Lwm8aun0tsPdBgr3D5Mzo02aGihHL0BmXnmzfR2qbmxQlxaYH-v-IKB2FeQFeMQWt8vFQSe__lSOo3g9ZlSra5mTliSksZh7TLDxBywpR6vOkLD8b1Lxf7ZO__iZLLj9-fmmRJeWQ38.RnnA1gpAajKrN8o4tZgGclc3GRE1tvm50y234Ah4mVE&dib_tag=se&keywords=37mm%2Buv%2Blens%2Bfilter&qid=1715135313&sprefix=37%2Bmm%2BUV%2Bl%2Caps%2C316&sr=8-4-spons&sp_csd=d2lkZ2V0TmFtZT1zcF9hdGY&th=1)
+
+### Step 1 - enclosure, camera and mounts
+The internal tray is the same for both the extruded aluminium or 3D printed enclosures. The 3D printed tray suits the
+Raspberry Pi HQ Camera and the Global Shutter Camera. A separate mount is available for the Camera Module 3. Details are
+provided below.
+
+Begin camera installation by removing the adapter ring and fitting the lens. The camera will not focus with this
+ring.
+
+| Global Shutter camera with adapter ring atached | Adapter ring removed |
+|---------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------|
+|  |  |
+
+
+Mount the camera to the front of the tray using M2.5 standoffs. The Global Shutter camera requires slightly longer
+standoffs to get past the plastic backing cover. Run the ribbon cable over the top of the tray (as pictured)
+
+| Internal tray | Mounting the camera | Mounting the camera |
+|-------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------|
+|  |  |  |
+
+Once the camera is secured, mount the Raspberry Pi using 4 x M2.5 x 5mm long standoffs. To secure the Pi, use 4 x 15mm
+standoffs. These will be used to mount the HAT.
+
+| Raspberry Pi 4B mounted on tray |
+|-------------------------------------------------------------------------------------------------------------------------------|
+|  |
+
+#### 3D printed enclosure
+The 37 mm UV lens filter is installed on the faceplate with 8 M2 heat-set threaded inserts and M2 hex head screws. Add
+the 1.6mm nitrile rubber o-ring cord to the internal channel on the lens mount. Firmly press the 37 mm UV lens filter into the
+channel on the faceplate.
+
+Tighten down the 8 M2 screws in a cross pattern (similar to how a car tyre is installed), to avoid cracking the 3D printed
+mount.
+
+| Faceplate | Lens mount | Lens fitted with o-ring |
+|-------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------|
+|  |  |  |
+
+The 3D printed enclosure also supports the mounting of the Camera Module 3 with an extra 3D printed part. Using M2 threaded
+inserts in the faceplate, mount the plate with approx. 5mm long M2 standoffs. Route the camera ribbon cable over the top.
+
+| Camera mounted on backplate | Faceplate with standoffs and backplate | Assembled |
+|-------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------|
+|  |  |  |
+
+
+### Step 2 - connecting the Official OWL HAT
+The OWL Hat simply fits over the GPIO pins and is mounted using the 4 x 15 mm standoffs installed in the previous step.
+Secure the HAT with 4 x 2.5mm screws and tighten down.
+
+PWM or GPIO control is selected with four jumper pins (in blue below).
+
+| Fitted Official OWL HAT | OWL Hat jumpers | GPIO pin assignment |
+|--------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------|
+|  |  |  |
+
+The default option is for GPIO control as pictured. By default the OWL HAT is wired as shown above:
+
+```ini
+[Relays]
+# defines the relay ID (left) that matches to a boardpin (right) on the Pi.
+# Only change if you rewire/change the relay connections.
+0 = 13
+1 = 15
+2 = 16
+3 = 18
+```
+The final jumper pin `Pi Power Supply Enable` connects the Raspberry Pi to the 5V provided by the HAT. Only connect this jumper once you want the
+Pi to start.
+
+#### Step 2a - connecting a generic relay HAT
+
+Instead of the Official OWL HAT, a relay control HAT (such as [this from PiHut](https://core-electronics.com.au/pirelay-v2-relay-board-for-raspberry-pi-1.html?))
+can be used, with some minor changes to the OWL software. There are many different relay HATs available, so check which
+is most suitable for your purposes. Be sure to choose one with accessible GPIO pins.
+
+Fit the HAT as recommended by the manufacturer, similar to the below images (source: The PiHut).
+
+| HAT | Installed HAT |
+|-----|---------------|
+|  |  |
+
+In its default configuration, this specific relay HAT assigns GPIO boardpins 29, 31, 33, and 35 to relays 4 - 1
+respectively. This differs to the default OWL configuration, so the `[Relays]` section of the config file would need to
+be updated. Using this board as the example:
+
+```ini
+[Relays]
+# defines the relay ID (left) that matches to a boardpin (right) on the Pi.
+# Only change if you rewire/change the relay connections.
+0 = 35
+1 = 33
+2 = 31
+3 = 29
+```
+ However, if you use another relay HAT check the assignment/configuration of relays to boardpins. For reference, use this
+ GPIO guide to help.
+
+
+
+
+
+#### Step 2b - connecting the voltage regulator
+Without the OWL HAT, 5V power to Pi needs to be supplied separately. We recommend the [Pololu 5V 5.5A step down voltage
+regulator](https://core-electronics.com.au/pololu-5v-5-5a-step-down-voltage-regulator-d36v50f5.html), however,
+there are many options available.
+
+Solder wires to the input and output of the voltage regulator. Using WAGO connector blocks, connect the input to the 12V
+from the Amphenol connector. Mount the regulator to the underside of the internal tray using M2 standoffs.
+
+
+
+
+
+##### Raspberry Pi 3B+ or 4B
+The earlier models of the Raspberry Pi consumer less power (3A @ 5V) than the Raspberry Pi 5 and can be powered over single 5V and GND
+pins on the GPIO. Use high quality connectors here or consider soldering directly to the GPIO pins on the HAT. A good
+connection without risk of coming loose, is critical.
+
+You'll need to solder to pins 2 (5V) and 6 (GND) on the relay HAT. More details provided
+[here](https://thepihut.com/blogs/raspberry-pi-tutorials/how-do-i-power-my-raspberry-pi) from The PiHut (image source).
+
+
+
+
+
+##### Raspberry Pi 5
+The Raspberry Pi 5 consumes up to 5A @ 5V, so it's suggested to use 2 x 5V and 2 x GND pins on the Raspberry Pi.
+Some good information on the topic is provided [here](https://forums.raspberrypi.com/viewtopic.php?t=367896).
+
+Using the two 5V outputs from the voltage regulator, solder one +5V wire to pin 2 and another to pin 4 on the GPIO on
+the HAT relay. Similarly, connect two GND wires from the voltage regulator output to pins 30 and 34. Ensure there is a
+good solder connection, without any short circuits to neighbouring pins.
+
+The images below are from a Raspberry Pi 5, however, the setup is the same for the 4B and 3B+ models just with one 5V/GND
+wire.
+
+| HAT installation - GPIO | HAT installation - soldering the 2 x 5V, 2x GND | HAT Installation - voltage regulator |
+|---------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------|
+|  |  |  |
+
+### Step 3 - wiring the connector and HAT
+Begin by wiring the [Amphenol EcoMate Aquarius receptacle](https://au.mouser.com/ProductDetail/Amphenol-SINE-Systems/FLS710N3W3S03?qs=ulEaXIWI0c8tWp%252BkBCr3Ag%3D%3D). The connector has 3 x 16 guage connections rated to 13A and 3 x 20
+guage rated up to 7.5A (machined) or 5A (stamped). Use the appropriate crimp connections for the 16 and 20 guage connections.
+
+
+
+
+
+Connections are labeled A - F on the receptacle and should be made in the following order:
+
+1. +12V (red) - A
+2. GND (black) - E
+3. relay 1 (blue) - B
+4. relay 2 (green) - C
+5. relay 3 (orange) - D
+6. relay 4 (white) - F
+
+Unlike the relay HATs and relay board in the Original OWL, the common ground for the OWL driver board is routed through
+the board itself, reducing the wiring required. Connect the wires from the back of the connector to each relay,
+using the above list as a guide. The finished result should appear similar to the images below. Add heat shrink at
+the end of each wire for neater and more reliable connections.
+
+| Connector with wires | Completed HAT | Completed HAT mounted on the Pi |
+|-------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------|
+|  |  |  |
+
+>**OPTIONAL** Add a 5V buzzer inside the OWL by mounting it to the corner of the HAT with a screw. Connect the 5 V and ground wires
+to Raspberry Pi GPIO pins 7 and 9, respectively. The buzzer is useful for identifying when the OWL has started successfully.
+It isn't essential to operation.
+
+>**OPTIONAL** Add Kapton tape to the camera cable and internal wiring. Kapton tape is a god insulator and resistant to high
+temperatures, improving the robustness of the device.
+
+| Tray with Kapton tape |
+|-------------------------------------------------------------------------------------------------------------------------------|
+|  |
+
+
+### Step 4 - inserting the tray and closing the device
+>⚠️**NOTE**⚠️ For software installation, you'll need access to the Raspberry Pi display, and USB ports. We recommend you
+set up the software prior to completing the build and inserting the tray. Alternatively, flash the SD card with one of
+the provided owl disk images.
+
+To improve the resistance to dust and water ingression on the 3D printed version, you'll need to add a 3mm nitrile rubber
+o-ring around the face- and backplates of the enclosure.
+
+| Faceplate with o-ring | Backplate with o-ring |
+|--------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------|
+|  |  |
+
+Fix the faceplate to the enclosure with the 4 x M4 screws. The 3D printed enclosure will need 4 x M4 threaded inserts set
+into the plastic on the back and front of the enclosure body. Carefully push the tray into the enclosure on the second row,
+making sure wires are not caught up on the side.
+
+
+
+
+
+For the single Amphenol EcoMate Aquarius receptacle, push it through the backplate and tighten down. Fix the backplate
+to the enclosure.
+
+| Front | Back |
+|-------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------|
+|  |  |
+
+
+There is a choice of three different backplates, depending on your hardware requirements. They include:
+1. 1 x hole for the Amphenol EcoMate Aquarius
+2. 2 x holes for the Amphenol EcoMate Aquarius and Adafruit waterproof RJ45 (ethernet) connector
+3. 1 x 16mm hole for a 16mm cable gland.
+
+If you would prefer a different arrangement, just get in touch or raise an issue and we can sort it out for you!
+
+| Backplate options (aluminium) | Backplate options (3D printed) |
+|-------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------|
+|  |  |
+
+And you're all done! Congratulations on building your OWL.
+
+
+
+
+ Connecting Solenoids for Spot Spraying
+
+
+### Optional Step - connecting 12V solenoids
+
+Once you have completed the setup, you now have the opportunity to wire up your own solenoids for spot spraying,
+targeted tillage, spot flaming or any other targeted weed control you can dream up. To do this, wire the GND wire of
+your device (it can be any wire if it's a solenoid) to the ground pin on the Bulgin plug (the same wire used for the GND
+from the 12V power source) and wire the other to one of the blue, green, orange or white wires on pins 1 - 4. A wiring
+diagram is provided below. The easiest way to wire them together to the same GND wire is to create a six-way harness,
+where one end is connected to the plug, one of the five other wires to the source power GND and the remaining four to
+the solenoids or whatever devices you are driving.
+

Bulgin plug | Ground wiring harness
:-------------: | :-------------:
 | 
+
-
+
+### SBC Options
+
+A single board computer or SBC is the brains behind the OWL. It can do all the image processing and logic within a
+single, roughly credit card sized board without moving parts. These SBCs are the backbone of embedded computing or 'edge
+computing'. While the Raspberry Pi is arguably one of the most widely used and well supported SBCs there are many
+different options out there. Each has their strengths and weaknesses and may or may not be good fits with the OWL. We've
+providing a summary of some SBCs below, but this isn't an exhaustive list.
+
+Currently, only Raspberry Pi 5, 4 and 3B+ work with the OWL and have been tested in full. Early tests (alpha) have been
+made with the LibreComputer LePotato. We will update the 'Works with OWL' column as more boards are tested in the
+community.
+
+
+ A summary of possible single board computers (SBCs) to use with the OWL
+
+
+| Name | CPU | RAM | MIPI | USB | GPU | Pros | Cons | Dimensions | OWL? | Image |
+|-----------------------------------------------------------------------------------------------------------|----------------------------------------------------------------|-------|------|--------------|-------------------|--------------------------------------------------------------------------------------------------------------|----------------------------------------------|-------------------|------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------|
+| [Raspberry Pi 5](https://datasheets.raspberrypi.com/rpi5/raspberry-pi-5-product-brief.pdf) | Broadcom BCM2712, quad-core 64-bit ARM Cortex-A76 | 1-8GB | 2 | 2x3.0, 2x2.0 | VideoCore VII | Large community, affordable, PCIe 2.0 lane for externals (including Google Coral and solid state harddrives) | Limited GPU performance, no eMMC storage | 88 x 58 x 19.5mm | :heavy_check_mark: | Manual install only (disk image coming soon) |
+| [Raspberry Pi 4B](https://www.raspberrypi.com/products/raspberry-pi-4-model-b/specifications/) | Broadcom BCM2711, quad-core Cortex-A72 | 1-8GB | 2 | 2x3.0, 2x2.0 | VideoCore VI | Large community, affordable | Limited GPU performance, no eMMC storage | 88 x 58 x 19.5mm | :heavy_check_mark: | [OWL v1.0.0](https://www.dropbox.com/s/ad6uieyk3awav9k/owl.img.zip?dl=0) |
+| [Raspberry Pi 3B+](https://www.raspberrypi.com/products/raspberry-pi-3-model-b-plus/) | Broadcom BCM2837B0, quad-core Cortex-A53 | 1GB | 1 | 4x2.0 | VideoCore IV | Large community, affordable | Limited GPU performance, no eMMC storage | 85 x 56 x 17mm | :heavy_check_mark: | [OWL v1.0.0](https://www.dropbox.com/s/ad6uieyk3awav9k/owl.img.zip?dl=0) |
+| [Raspberry Pi CM4](https://www.raspberrypi.com/products/compute-module-4/?variant=raspberry-pi-cm4001000) | Broadcom BCM2711 quad-core Cortex-A72 | 1-8GB | 0 | 0 | VideoCore IV | Integration into custom carrier boards, EMMC | Needs carrier board | 55 x 40 x 4.7mm | - | - |
+| [Libre Computer LePotato](https://libre.computer/products/aml-s905x-cc/) | Amlogic S905X | 1/2GB | 0 | 4x2.0 | Mali-450 @ 750MHz | Affordable | Limited community support, no onboard Wi-Fi | 85 x 56mm | :warning: alpha ([full report here](https://github.com/geezacoleman/OpenWeedLocator/discussions/70)) | TBA |
+| [Libre Computer Renegade](https://libre.computer/products/roc-rk3328-cc/) | Rockchip RK3328, 4 Core Cortex-A53 | 1-4GB | 0 | 1x3.0, 2x2.0 | Mali-450 @ 500MHz | 4K HDR support | Limited community support, no onboard Wi-Fi | 85 x 56mm | - | - |
+| [Libre Computer Renegade Elite](https://libre.computer/products/roc-rk3399-pc/) | Rockchip RK3399, 2 Core Cortex-A72 + 4 Core Cortex-A53 | 4GB | 2 | 4x3.0 | 4 Core Mali-T860 | PCIe, highest performance Libre Computer | Higher cost compared to other options | 128 x 64mm | - | - |
+| [Rock Pi 4B](https://rockpi.org/rockpi4) | Rockchip RK3399, 2 Core Cortex-A72 + 4 Core Cortex-A53 | 4GB | 1 | 2x2.0 2x3.0 | 4 Core Mali-T860 | PCIe, M.2 slot | Limited community support, no onboard Wi-Fi | 85 x 54mm | - | - |
+| [ODROID-XU4](https://wiki.odroid.com/odroid-xu4/odroid-xu4) | Samsung Exynos5422 ARM Cortex-A15 Quad 2Ghz and Cortex-A7 Octa | 2GB | 0 | 2x3.0, 1x2.0 | Mali-T628 MP6 | eMMC module support | Higher cost compared to Raspberry Pi options | 83 x 58 x 20mm | - | - |
+| [NVIDIA Jetson Nano](https://developer.nvidia.com/embedded/jetson-nano-developer-kit) | 4 Core ARM Cortex-A57 | 2/4GB | 2 | 4x3.0 | 128-core Maxwell | Powerful GPU, CSI camera | Higher cost compared to Raspberry Pi options | 100 x 79 x 30.2mm | - | - |
+
+Only the Raspberry Pi 5 is currently capable of operating on larger image sizes. Frame rates of up to 120 FPS were recorded at the
+default 416 x 320 resolution. We recommend increasing resolution to 640 x 480 for the Raspberry Pi 5.
+
+Want to help fill in this table? Find one of the untested platforms and give the OWL a go!
+
+NVIDIA has released numerous powerful, [embedded computers](https://www.nvidia.com/en-us/autonomous-machines/) such as
+the Jetson Orin series (and previously the Jetson Xavier NX). These would likely be good options for the OWL, but are
+substantially more expensive than the options listed above.
+
+
+
# Software
-The project will eventually support the use of the two major embedded computing devices, the Raspberry Pi (models 3B+ and 4) and the Jetson Nano/Jetson Xavier NX for possible green-on-green detection with deep learning algorithms. At present, just the details on setting up the Raspberry Pi 3B+/4 are provided below. There are two options for installation. For the first, all you'll need to do is download the disk image file (vX.X.X-owl.img) and flash it to an SD card. The second method is more in depth, but takes you through the entire process from beginning to end. If you're looking to learn about how everything works, take some time to work through this process.
+Installing the OWL software is straightforward and can be automated for a simple, two-line install. Alternatively, you can
+take the step-by-step alternative to see what is happening under the hood.
+
+Both begin by flashing the latest Raspbian operating system to an SD card and booting up a Raspberry Pi.
+
+Steps:
+1. Set up Raspbian on the Raspberry Pi
+2. Either the **Two-line installation** (10 mins) OR **Detailed installation** (60 mins)
-## Quick Method
-For this method you'll need access to:
-* Desktop/laptop computer
-* Micro SD card reader
-* Internet with large data capacity and high speed (WARNING: the image file is large and downloading will take time and use up a substantial quantity of your data allowance if you have are on a limited plan)
+The project will eventually support the use of the two major embedded computing devices, the Raspberry Pi (models 3B+, 4 and 5)
+and the Jetson Nano/Jetson Xavier NX for possible green-on-green detection with deep learning algorithms. At
+present, just the details on setting up the Raspberry Pi 3B+/4/5 are provided below. There are two options for
+installation.
+
+>⚠️**NOTE**⚠️ 08/05/2024 - OWL transitioned from `picamera` to `picamera2` support. The v1.0.0 disk image below (Buster) does not
+support `picamera2` and will not work on the Raspberry Pi 5 nor with the recent camera releases. We strongly recommend
+using the most up to date version of Raspbian with the latest OWL software.
+
+>⚠️**NOTE**⚠️ 17/03/2023 - running of the OWL changed from using `greenonbrown.py` to `owl.py`. This
+ensures better cross compatibility with GoG algorithms. It improves the modularity of the system.
-Quick method for software installation
+Step 1: Raspbian Installation
-### Step 1 - download the disk image file
-Download the entire disk image file (v1.0.0-owl.img) here: [OWL disk image](https://www.dropbox.com/s/ad6uieyk3awav9k/owl.img.zip?dl=0)
+### Step 1 - Raspberry Pi setup
-The latest, stable version will be linked above, however, all other older versions or versions with features being tested are available [here](#version-history).
+#### Step 1a - Rasperry Pi OS
+Before powering up the Raspberry Pi, you'll need to install the Raspian operating system (just like Windows/MacOSX for
+laptops) on the new SD card. This is done using the same process as the quick method used to flash the premade owl.img
+file, except you'll be doing it with a completely new and untouched version of Raspbian.
-### Step 2 - flash owl.img to SD card
-The easiest way to flash (add the vX.X.X-owl.img file to the SD card so it can boot) the SD card is to use Balena Etcher or any other card flashing software. Instructions for Balena Etcher are provided here. Navigate to the [website](https://www.balena.io/etcher/) and download the relevant version/operating system. Install Balena Etcher and fire it up.
+From your own computer, download and flash the latest **64-bit** version of Raspian from the official
+[Raspberry Pi website](https://www.raspberrypi.com/software/operating-systems/#raspberry-pi-os-64-bit) to the empty SD card.
+The official [Raspberry Pi Imager](https://www.raspberrypi.com/software/) is a good piece of software to use.
-
+Leave the hostname as default. To simplify setup, you can specify the wifi network settings. Set the username to 'owl'
+and choose a password.
-* Insert the SD card using your SD card reader.
-* Select `Flash from file` on the Balena Etcher window and navigate to where you downloaded the vXX-XX-XX-owl.dmg file. This can be a zip file (compressed) too.
-* Select the target, the SD card you just inserted.
-* Click `Flash`
+| Raspberry Pi Imager | Configuring the OWL |
+|-----------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------|
+|  |  |
-If this completes successfully, you're ready to move to the next step. If it fails, use Balena Etcher documentation to diagnose the issue.
+#### Step 1b - Setting up the OWL
+Once the Raspian OS has been flashed to the SD Card (may take 5 - 10 minutes), remove the SD card and insert it into the
+Raspberry Pi. Connect the screen, keyboard and mouse and then power up the Pi.
-### Step 3 - power up
-Once the SD card is inserted into the slot of the Raspberry Pi, power everything up and wait for the beep. If you hear the beep, you're ready to go and start focusing the camera.
+Alternatively, you can SSH into your OWL from a separate device and install it remotely. A good guide on how to do that is
+available [here](https://www.makeuseof.com/how-to-ssh-into-raspberry-pi-remote/).
-### Step 4 - updating the disk image
-The disk image that you downloaded is likely to be a few versions behind the most recent. We only provide images of the most major updates. So to update the OWL software, just run the follow these steps.
-
-1. Have the OWL powered on with screen, keyboard and mouse connected. You should see a desktop with the OWL logo.
-2. Press CTRL + ALT + T to open a Terminal window or click the black icon with blue line and >_ symbol.
-3. Once the Terminal window is open, make sure you are working in the `owl` virtual environment by running:
-```
-pi@raspberrypi:~ $ workon owl
-(owl) pi@raspberrypi:~ $
-```
-Notice that (owl) now appears before the line in the Terminal window. This indicates you are in the `owl` virtual environment. This is **critical** to make sure you install everything in the `requirements.txt` file into the right spot.
+##### First boot
+On the first boot you may be asked to set country, timezone, keyboard, connect to wifi and look for updates among other
+things. If you haven't already set the username, set it to 'owl' and choose a password. Uninstall the unused browser -
+this will save space on the Pi. Finally, you will be asked to restart the pi.
-4. Once you are in the `owl` environment, enter these commands on each new line:
-
-```
-(owl) pi@raspberrypi:~ $ cd ~
-(owl) pi@raspberrypi:~ $ mv owl owl-old # this renames the old 'owl' folder to 'owl-old'
-(owl) pi@raspberrypi:~ $ git clone https://github.com/geezacoleman/OpenWeedLocator # download the new software
-(owl) pi@raspberrypi:~ $ mv OpenWeedLocator owl # rename the download to 'owl'
-(owl) pi@raspberrypi:~ $ cd ~/owl
-(owl) pi@raspberrypi:~/owl $ pip install -r requirements.txt # installs the necessary software into the (owl) environment
-(owl) pi@raspberrypi:~/owl $ chmod a+x greenonbrown.py # changes greenonbrown.py to be executable
-(owl) pi@raspberrypi:~/owl $ chmod a+x owl_boot.sh # changes owl_boot.sh to be executable
-```
-Once this is complete your software will be up to date and you can move on to focusing the camera.
+##### Opening terminal
+After the restart, open up Terminal. You can press CTRL + ALT + T, or click the icon in the top left with the `>_`
+symbol. The instructions that follow are a blend of those available from
+[PyImageSearch](https://pyimagesearch.com/2019/09/16/install-opencv-4-on-raspberry-pi-4-and-raspbian-buster/)
+and [QEngineering](https://qengineering.eu/bookworm.html)
-### Step 5 - focusing the camera
-The final step in the process is to make sure the camera and lens are correctly focused for the mounting height. To view the live camera feed, we need to stop the process that is running in the background that would have started when you first turned on the OWL. Enter the following into the terminal:
-```
-(owl) pi@raspberrypi:~ $ ps -C greenonbrown.py
-```
-After pressing ENTER, you should receive the following output:
-```
-(owl) pi@raspberrypi:~ $ ps -C greenonbrown.py
-PID TTY TIME CMD
-515 ? 00:00:00 greenonbrown.py
-```
-The PID is the important part, it's the ID number for the `greenonbrown.py` program. In this case it is 515, but it is likely to be different on your OWL. If the headings `PID TTY TIME CMD` appear but a PID/line for greenonbrown.py doesn't appear it could mean two things. Firstly make sure you've typed `greenonbrown.py` correctly. If it doesn't have the right program to look for, it won't find it. The other option is that `greenonbrown.py` isn't running, which may also be the case. If you're certain it's not running in the background, skip the stop program step below, and move straight to launching `greenonbrown.py`.
-
-If a PID appears, you'll need to stop it operating. To stop the program, enter the following command:
-```
-(owl) pi@raspberrypi:~ $ sudo kill enter_your_PID_number_here
-```
-The program should now be stopped
-
-Now you'll need to launch `greenonbrown.py` manually with the video feed visible. To do this use the Terminal window and type the following commands:
-```
-(owl) pi@raspberrypi:~ $ ~/owl/./greenonbrown.py --show-display
-```
-This will bring up a video feed you can use to visualise the OWL detector and also use it to focus the camera. Once you're happy with the focus, press Esc to exit.
+>⚠️**NOTE**⚠️ All commands below are provided for easy copy/paste. When using terminal you should see `owl@raspberrypi:~ $`
+> at the start of each line and `(owl) owl@raspberrypi:~ $` when operating within the `owl` virtual environment. Pay very close
+> attention to the presence/absence of `(owl)` in front of each line, as this can make/break installation.
-### OPTIONAL Step 6 - enabling UART for status LED
-This is just the cherry on top and non-essential to correct operation of the OWL but to make sure the status LED you connected earlier blinks correctly the GPIO UART needs to be enabled.
+>⚠️**NOTE**⚠️ We recommend naming the device `owl` when asked if you didn't set it during the flashing process.
-Open up a terminal console by pressing `Ctrl + T`. Type:
+
+
+Step 2: Quick method for software installation
+
+
+## Step 2: Quick Method - Two-line install
+>⚠️**NOTE**⚠️: *Two line install is suitable for the Raspberry Pi 5 and Bookworm Raspberry Pi OS.*
+
+#### Two-line Install
+If you prefer a faster and simpler installation, try the following two-line install. You'll first need to clone the owl repository
+before running `owl_setup.sh`.
+
+To start, clone the Github repository:
+```commandline
+git clone https://github.com/geezacoleman/OpenWeedLocator owl
```
-(owl) pi@owl :-$ sudo nano /boot/config.txt
-```
+With the repository cloned into the 'owl' folder, we can now run the bash script `owl_setup.sh`. This will take some time
+to complete
-This will open up the config.txt file. Scroll down to the bottom by holding the down arrow key and add the following line to the very last line of the file:
+```commandline
+bash owl/owl_setup.sh
```
-enable_uart=1
+Once completed successfully, your OWL is ready to go and you only need to focus the camera. You should see a table at the
+end similar to the following:
+
+```bash
+Installation Summary:
+🟢 [OK] System Upgrade
+🟢 [OK] Camera Detected
+🟢 [OK] Camera Test
+🟢 [OK] Virtual Environment Created
+🟢 [OK] OpenCV Installed
+🟢 [OK] OWL Dependencies Installed
+🟢 [OK] Boot Scripts Moved
```
-Press `ctrl + x` to exit, then type `y` to save and then `enter`.
+>⚠️**NOTE**⚠️ If you use this method, you can finish the installation here. The following steps just go through what is
+> in the `owl_setup.sh` script step-by-step. We recommend the step-by-step approach if you want to become more familiar
+> with how the OWL works.
+
+### Focusing the camera
+
+The final step in the process is to make sure the camera is correctly focused for the mounting height. With the latest
+software, when you run `owl.py --focus` a sharpness (i.e. least blurry) estimation is provided on the video feed. The
+algorithm determines how sharp an image is, so the higher the value the better.
+
+| Blurry Image | Clear Image |
+|--------------|-------------|
+| |  |
+
-You're now ready to run!
-
+
+
+Step 2: Detailed OWL installation procedure
+
+
## Detailed Method
-This setup approach may take a little longer (about 1 hour total) than the quick method, but you'll be much better trained in the ways of OWL and more prepared for any problem solving, upgrades or changes in the future. You'll also download and use the latest software that hasn't been saved in the .img file yet. In the process you'll learn about Python environments, install Python packages and set it all up to run on startup. To get this working you'll need access to:
+**IMPORTANT**: *Suitable for the Raspberry Pi 5 and Bookworm Raspberry Pi OS.*
+
+This setup approach may take a little longer (aproximately 1 hour total) than the quick method, but you'll be much better
+trained in the ways of OWL and more prepared for any problem solving, upgrades or changes in the future. You'll also
+download and use the latest software that hasn't been saved in the .img file yet. In the process you'll learn about
+Python environments, install Python packages and set it all up to run on startup. To get this working you'll need access
+to:
+
* Raspberry Pi
* Empty SD Card (SanDisk 32GB SDXC ideally)
* Your own computer with SD card reader
@@ -396,169 +994,332 @@ This setup approach may take a little longer (about 1 hour total) than the quick
* Screen and keyboard
* WiFi/Ethernet cable
-
-Detailed OWL installation procedure
-
+#### Step-by-step install
+Instead of the two-line installation, the following procedure details all steps required.
-### Step 1 - Raspberry Pi setup
-Before powering up the Raspberry Pi, you'll need to install the Raspian operating system (just like Windows/MacOSX for laptops) on the new SD card. This is done using the same process as the quick method used to flash the premade owl.img file, except you'll be doing it with a completely new and untouched version of Raspbian. To get the Raspberry Pi to the stage at which we can start installing OWL software, follow [these instructions](https://www.pyimagesearch.com/2019/09/16/install-opencv-4-on-raspberry-pi-4-and-raspbian-buster/) from Adrian Rosebrock at PyImageSearch. They are very well written, detailed and if you're interested in computer vision, the rest of the PyImageSearch blog posts are very useful.
+##### Free up space
+The Raspberry Pi comes pre-installed with a range of software. To free up space it can be removed from the OWL.
+Depending on your install, these may or may not be present. At the command line (it should look like `owl@raspberrypi:~ $`),
+run the following:
+```commandline
+sudo apt-get purge wolfram-engine
+```
+```commandline
+sudo apt-get purge libreoffice*
+```
+```commandline
+sudo apt-get clean
+```
+
+##### Set up the virtual environment
+A virtual environment contains all the necessary packages in one neat spot. We'll be using `virtualenv` and
+`virtualenvwrapper` on the Pi to create a virtual environment called `owl`.
+
+To start with, update the system. The update may take a few minutes depending on your internet connection and how many
+packages need updating. It's good practice to do this regularly. Then you'll add the following two lines to the `bashrc`
+file.
-**NOTE 1**:
-At **PyImageSearch Step 3** make sure to create a virtual environment `owl` (it *must* be named `owl` otherwise the software will not load) instead of `cv` as written in the guide.
+Begin by updating the system:
+```commandline
+sudo apt update && sudo apt full-upgrade
```
-$ mkvirtualenv owl -p python3
+
+Then add the following lines to the `.bashrc` file to prepare for the creation of virtual environments.
+```commandline
+echo "# virtualenv and virtualenvwrapper" >> ~/.bashrc
+```
+
+```commandline
+echo "export VIRTUALENVWRAPPER_PYTHON=/usr/bin/python" >> ~/.bashrc
+```
+Finally, reload the `.bashrc` file.
+```commandline
+source ~/.bashrc
+```
+Once that is complete, you can install `virtualenv` and `virtualenvwrapper` and add a few more lines to the same `bashrc`
+file.
+```commandline
+sudo apt-get install python3-virtualenv
+```
+```commandline
+sudo apt-get install python3-virtualenvwrapper
```
+Following the installation of these packages, add the following lines to the `.bashrc` file.
+```commandline
+echo "export WORKON_HOME=$HOME/.virtualenvs" >> ~/.bashrc
+```
+```commandline
+echo "source /usr/share/virtualenvwrapper/virtualenvwrapper.sh" >> ~/.bashrc
+```
+As above, reload the file to update the changes.
+```commandline
+source ~/.bashrc
+```
+With the virtual environment software successfully installed, it's now time to create the `owl` environment. Importantly,
+we need to inherit the site-packages (i.e. everything currently on the Pi) because they contain `picamera2` pre-installed.
+
+To make the `owl` environment, run the following:
+```commandline
+mkvirtualenv --system-site-packages -p python owl
+```
+The command line should now look like `(owl) owl@raspberrypi:~ $`. The '(owl)' at the start of the line means you're currently within
+that virtual environment. To turn it off you can run `deactivate` and to turn it run `workon owl`.
+
+**IMPORTANT**: The next steps must be run within the `owl` virtual environment. We're installing packages specific to the OWL.
-**NOTE 2**:
-At **PyImageSearch Step 4** you do not need to compile OpenCV from scratch, the pip install method (**Step 4a**) will be a LOT faster and perfectly functional for this project. Make sure you're in the owl virtual environment for this step by looking for (owl) at the start of the line, if it's not there type: `workon owl`
+### Step 2 - Installing packages
+We now need to install the Python libraries that let the OWL work. The most import is OpenCV, which we'll do first before
+downloading the OWL repository and installing the remainder from the `requirements.txt` file. Begin by ensuring the `owl`
+environment is active.
+
+>⚠️**WARNING**⚠️ It is ESSENTIAL that `(owl)` is present at the start of each line in terminal for this section. The `(owl)`
+> indicates that you are within the `owl` virtual environment and that these Python packages will be installed and accessible
+> within this environment. Run `workon owl` to ensure you are in the `owl` environment.
+
+```commandline
+workon owl
```
-(owl) pi@raspberrypi:~ $ pip install opencv-contrib-python==4.5.5.62
+Now that you are in the `owl` virtual environment we can begin installing packages.
+```commandline
+pip3 install opencv-contrib-python
```
+This should have successfully installed OpenCV into the `owl` virtual environment. You can double check by quickly starting a
+Python session at the command line:
+```commandline
+python
+```
+This will open up an interactive Python session (indicated by >>>) from which you should type:
-### Step 2 - enable camera
-We now need to enable the connection to the Raspberry Pi camera. This can be enabled in raspi-config:
+```commandline
+>>> import cv2
+>>> import picamera2
+>>> exit()
```
-(owl) pi@raspberrypi:~ $ sudo raspi-config
+This should then exit you from the Python session. The command line should look like this:
+
+```commandline
+(owl) owl@raspberrypi:~ $
```
-Select **3 Interface Options**, then select **P1 Camera**. Select **Yes** to enable the camera. You can now exit raspi-config and reboot.
-### Step 3 - downloading the 'owl' repository
+If both of these complete without error, then you've successfully set up the virtual environment and installed OpenCV.
+
+### Step 3 - Downloading the 'owl' repository
+
Now you should have:
+
* A virtual environment called 'owl'
* A working version of OpenCV installed into that environment
-* a Terminal window open with the 'owl' environment activated. If it is active (owl) will appear at the start of a new line in the terminal window. If you're unsure, run: `workon owl`
+* a Terminal window open with the 'owl' environment activated.
+
+The next step is to download the entire OpenWeedLocator repository into your *home* directory on the Raspberry Pi. First
+change into the home directory:
-The next step is to download the entire OpenWeedLocator repository into your *home* directory on the Raspberry Pi.
+```commandline
+cd ~
```
-(owl) pi@raspberrypi:~ $ cd ~
-(owl) pi@raspberrypi:~ $ git clone https://github.com/geezacoleman/OpenWeedLocator
-(owl) pi@raspberrypi:~ $ mv OpenWeedLocator owl
+then clone the repository 'https://github.com/geezacoleman/OpenWeedLocator' into a new folder called 'owl'
+```commandline
+git clone https://github.com/geezacoleman/OpenWeedLocator owl
```
-Double check it is there by typing `(owl) (owl) pi@raspberrypi:~ $ ls` and reading through the results, alternatively open up the Home folder using a mousee. If that was sucessful, you can now move on to Step 4.
+This will download the repository into a folder called `owl`. Double check it is there by typing `(owl) owl@raspberrypi:~ $ ls`
+and reading through the results, alternatively open up the Home folder using a mouse. If that was successful, you can
+now move on to Step 4.
+
+### Step 4 - Installing the OWL Python dependencies
+Dependencies are Python packages on which the code relies to function correctly. With a range of versions and possible
+comptibility issues, this is the step where issues might come up. There aren't too many packages, but please make sure
+each and every module in the requirements.txt file has been installed correctly. These include:
-### Step 4 - installing the OWL Python dependencies
-Dependencies are Python packages on which the code relies to function correctly. With a range of versions and possible comptibility issues, this is the step where issues might come up. There aren't too many packages, but please make sure each and every module in the requirements.txt file has been installed correctly. These include:
* OpenCV (should already be in 'owl' virtual environment from Step 1)
* numpy
* imutils
* gpiozero
-* pandas (for data collection only)
-* glob (for data collection only)
-* threading, collections, queue, time, os (though these are included as standard Python modules).
+* pandas
+* RPi.GPIO
+* tqdm
+* blessed (for command line visualisation)
+* threading, multiprocessing, collections, queue, time, os (though these are included as standard Python modules).
-**NOTE**: Before continuing make sure you are in the `owl` virtual environment. Check that `(owl)` appears at the start of each command line, e.g. `(owl) pi@raspberrypi:~ $`. Run `workon owl` if you are unsure. If you are not in the `owl` environment, you will run into errors when starting `greenonbrown.py`.
+>⚠️**IMPORTANT**⚠️ Before continuing make sure you are in the `owl` virtual environment. Check that `(owl)` appears at the start
+of each command line, e.g. `(owl) owl@raspberrypi:~ $`. Run `workon owl` if you are unsure. If you are not in the `owl`
+environment, you will run into errors when starting `owl.py`.
-To install all the requirements.txt, simply run:
-```
-(owl) pi@raspberrypi:~ $ cd ~/owl
-(owl) pi@raspberrypi:~/owl $ pip install -r requirements.txt
+To install all the requirements.txt, change into the owl directory:
+
+```commandline
+cd ~/owl
```
-It's very important that you're in the owl virtual environment for this, so double check that **(owl)** appears on the far left of the command line when you type the command in. Check these have been installed correctly by importing them in Python in the command prompt and check the package version. To do this:
+
+then run:
+
+```commandline
+pip install -r requirements.txt
```
-(owl) pi@owl :-$ python
+Now to double-check this has worked, we can open up another Python session and try importing the packages. Begin by typing:
+
+```commandline
+python
```
Python should start up an interactive session; type each of these in and make sure you don't get any errors.
-```
+
+```commandline
>>> import cv2
>>> import numpy
>>> import gpiozero
>>> import pandas
```
Version numbers can be checked with:
-```
+
+```commandline
>>> print(package_name_here.__version__) ## this is a generic example - add the package where it says package_name_here
>>> print(cv2.__version__)
-```
-
-If any errors appear, you'll need to go back and check that the modules above have (1) been installed into the owl virtual environment, (2) that Python was started in the owl environment, and/or (3) they all installed correctly. Once that is complete, exit Python and continue with the installation process.
-```
>>> exit()
```
+If any errors appear, you'll need to go back and check that the modules above have (1) been installed into the owl
+virtual environment, (2) that Python was started in the owl environment, and/or (3) they all installed correctly. Once
+that is complete, exit Python and continue with the installation process.
### Step 5 - starting OWL on boot
-Now that these dependencies have been installed into the owl virtual environment, it's time to make sure it runs on startup! The first step is to make the Python file `greenonbrown.py` executable using the Terminal window.
+
+Now that these dependencies have been installed into the owl virtual environment, it's time to make sure the software
+runs on startup. The first step is to make both the Python file `owl.py` and the boot files `owl_boot.sh` and `owl_boot_wrapper.sh`
+executable. Then move both `owl_boot.sh` and `owl_boot_wrapper.sh` into the `/usr/local/bin` directory.
+
+```commandline
+chmod a+x owl.py
+```
+```commandline
+chmod a+x owl_boot.sh
```
-(owl) (owl) pi@raspberrypi:~ $ chmod a+x ~/owl/greenonbrown.py
+```commandline
+chmod a+x owl_boot_wrapper.sh
```
-After it's been made executable, the file needs to be launched on startup so each time the Raspberry Pi is powered on, the detection systems starts. The easiest way to do this
-by using cron, a scheduler for starting code. So you'll need to add the `owl_boot.sh` file to the schedule so that it launches on boot. The `owl_boot.sh` file is fairly straightforward. It's what's known as a [bash script](https://ryanstutorials.net/bash-scripting-tutorial/bash-script.php) which is just a text file that contains commands we would normally enter on the command line in Terminal.
+```commandline
+sudo mv owl_boot.sh /usr/local/bin/owl_boot.sh
```
+```commandline
+sudo mv owl_boot_wrapper.sh /usr/local/bin/owl_boot_wrapper.sh
+```
+
+After they have been made executable, the `owl.py` needs to be launched on startup so each time the Raspberry Pi is
+powered on, the detection systems starts. The easiest way to do this is by using cron, a scheduler for starting code.
+We need to add the `owl_boot.sh` file to the schedule so that it launches on boot. The `owl_boot.sh` file is
+fairly straightforward. It's what's known as a [bash script](https://ryanstutorials.net/bash-scripting-tutorial/bash-script.php) which is just a text file that
+contains commands we would normally enter on the command line in Terminal.
+
+This is the `owl_boot.sh` file:
+```commandline
#!/bin/bash
-source /home/pi/.bashrc
-workon owl
-lxterminal
-cd /home/pi/owl
-./greenonbrown.py
+# automatically determine the home directory, to avoid issues with username
+source $HOME/.bashrc
+
+# activate the 'owl' virtual environment
+source $HOME/.virtualenvs/owl/bin/activate
+
+# change directory to the owl folder
+cd $HOME/owl
+
+# run owl.py in the background and save the log output
+LOG_DATE=$(date -u +"%Y-%m-%dT%H-%M-%SZ")
+./owl.py > $HOME_DIR/owl/logs/owl_$LOG_DATE.log 2>&1 &
```
-In the file, the first two commands launch our `owl` virtual environment, then `lxterminal` creates a virtual terminal environment so outputs are logged. Finally we change directory `cd` into the owl folder and run the python program.
+
+In the file, the first two commands launch our `owl` virtual environment, then we change directory `cd` into the owl
+folder and run the python program. The `owl_boot_wrapper.sh` file figures out which user is running the file, and then
+uses that user to run `owl_boot.sh`. This makes the system more resilient to changes in Pi username.
To add this to the list of cron jobs, you'll need to edit it as a root user:
+
+```commandline
+sudo crontab -e
```
-(owl) pi@raspberrypi:~ $ sudo crontab -e
-```
-Select `1. /bin/nano editor`, which should bring up the crontab file. At the base of the file add:
-```
-@reboot /home/pi/owl/owl_boot.sh
-```
-Once you've added that line, you'll just need to save the file and exit. In the nano editor just press Ctrl + X, then Y and finally press Enter to agree to save and exit.
-Finally you just need to make `owl_boot.sh` executable so it can be run on startup:
+Select `1. /bin/nano editor`, which should bring up the crontab file. At the base of the file add this text:
+
```
-(owl) pi@raspberrypi:~ $ chmod a+x ~/owl/owl_boot.sh
+@reboot /usr/local/bin/owl_boot_wrapper.sh > /home/launch.log 2>&1
```
-If you get stuck, [this guide](https://www.makeuseof.com/how-to-run-a-raspberry-pi-program-script-at-startup/) or [this guide](https://www.tomshardware.com/how-to/run-script-at-boot-raspberry-pi) both have a bit more detail on cron and some other methods too.
+Once you've added that line, you'll just need to save the file and exit. In the nano editor just press Ctrl + X, then Y
+and finally press Enter to agree to save and exit.
+
+If you get stuck, [this guide](https://www.makeuseof.com/how-to-run-a-raspberry-pi-program-script-at-startup/) or [this guide](https://www.tomshardware.com/how-to/run-script-at-boot-raspberry-pi) both have a bit more detail on cron and some other methods too.
+
+Now you'll just need to reboot the system. Once it reboots, `owl.py` will launch and run in the background.
### Step 6 - focusing the camera
-The final step in the process is to make sure the camera and lens are correctly focused for the mounting height. To view the live camera feed, we need to stop the process that is running in the background that would have started when you first turned on the OWL. Enter the following into the terminal:
-```
-(owl) pi@raspberrypi:~ $ ps -C greenonbrown.py
+
+>⚠️**NOTE**⚠️ *Cameras with automatic focus such as the Raspberry Pi Camera Module 3 will be automatically focused to 1.2m
+distance. The following guide is useful for the HQ and Global Shutter cameras which require manual focusing.*
+
+The final step in the process is to make sure the camera is correctly focused for the mounting height. With the latest
+software, when you run `owl.py --focus` a sharpness (i.e. least blurry) estimation is provided on the video feed. The
+algorithm determines how sharp an image is, so the higher the value the better.
+
+This will automate all the steps below. If this doesn't work, follow the steps below. If you would like to focus the OWL
+again, you can always run `./owl.py --focus`.
+
+| Blurry Image | Clear Image |
+|--------------|-------------|
+| |  |
+
+#### Manual focusing
+
+With the older versions of the software, you need to stop all `owl.py` background processes before
+you can restart the software with the video feed viewable on the screen. Enter the following into the terminal:
+
+```commandline
+ps -C owl.py
```
+
After pressing ENTER, you should receive the following output:
+
```
-(owl) pi@raspberrypi:~ $ ps -C greenonbrown.py
+(owl) owl@raspberrypi:~ $ ps -C owl.py
PID TTY TIME CMD
-515 ? 00:00:00 greenonbrown.py
-```
-The PID is the important part, it's the ID number for the `greenonbrown.py` program. In this case it is 515, but it is likely to be different on your OWL.
-
-To stop the program, you need to enter the following command:
-```
-(owl) pi@raspberrypi:~ $ sudo kill enter_your_PID_number_here
+515 ? 00:00:00 owl.py
```
-The program should now be stopped
-
-Now you'll need to launch `greenonbrown.py` manually with the video feed visible. To do this use the Terminal window and type the following commands:
-```
-(owl) pi@raspberrypi:~ $ ~/owl/./greenonbrown.py --show-display
-```
-This will bring up a video feed you can use to visualise the OWL detector and also use it to focus the camera. Once you're happy with the focus, press Esc to exit.
-### OPTIONAL Step 6 - enabling UART for status LED
-This is just the cherry on top and non-essential to correct operation of the OWL but to make sure the status LED you connected earlier blinks correctly the GPIO UART needs to be enabled.
+The PID is the important part, it's the ID number for the `owl.py` program. In this case it is `515`, but it is likely
+to be different on your OWL.
-Open up a terminal console by pressing `Ctrl + T`. Type:
+>⚠️**IMPORTANT**⚠️ If the headings `PID TTY TIME CMD` appear but a PID/line for owl.py doesn't appear it could mean
+two things. Firstly make sure you've typed `owl.py` correctly. If it doesn't have the right program to look for, it
+won't find it. The other option is that `owl.py` isn't running, which may also be the case. If you're certain it's not
+running in the background, skip the stop program step below, and move straight to launching `owl.py`.
-```
-(owl) pi@owl :-$ sudo nano /boot/config.txt
-```
+If a PID appears, you'll need to stop it operating. To stop the program, enter the following command:
-This will open up the config.txt file. Scroll down to the bottom by holding the down arrow key and add the following line to the very last line of the file:
-```
-enable_uart=1
+```commandline
+sudo kill enter_your_PID_number_here
```
-Press `ctrl + x` to exit, then type `y` to save and then `enter`.
+The program should now be stopped
+
+Now you'll need to launch `owl.py` manually with the video feed visible. To do this use the Terminal window and type the
+following commands:
-You're now ready to run!
+```commandline
+~/owl/./owl.py --show-display
+```
+
+This will bring up a video feed you can use to visualise the OWL detector and also use it to focus the camera. Once
+you're happy with the focus, press Esc to exit.
### Step 8 - reboot
-The moment of truth. Shut the Raspberry Pi down and unplug the power. This is where you'll need to reconnect the camera and all the GPIO pins/power in the OWL unit if they have been disconnected. Once everything is connected again (double check the camera cable is inserted or this won't work), reconnect the power and wait for a beep!
-If you hear a beep, grab something green and move it under the camera. If the relays start clicking and lights come on, congratulations, you've successfully set the OWL up! If not, check the troubleshooting chart below and see if you can get it fixed.
+The moment of truth. Shut the Raspberry Pi down and unplug the power. This is where you'll need to reconnect the camera
+and all the GPIO pins/power in the OWL unit if they have been disconnected. Once everything is connected again (double
+check the camera cable is inserted or this won't work), reconnect the power and wait for a beep!
+
+If you hear a beep, grab something green and move it under the camera. If the relays start clicking (the Official OWL
+HAT uses transistors and will not click - look for the lights) and lights come on, congratulations, you've successfully
+set the OWL up! If not, check the troubleshooting chart below and see if you can get it fixed. Raise an issue and get in touch
+if you're not sure how to proceed.
-**NOTE** The unit does not perform well under office/artificial lighting. The thresholds have been set for outdoor conditions.
+>⚠️**NOTE**⚠️ The unit does not perform well under office/artificial lighting. The thresholds have been set for outdoor
+conditions.
@@ -566,116 +1327,433 @@ If you hear a beep, grab something green and move it under the camera. If the re
The optional real time clock module can be set up by following the [detailed instructions](https://learn.adafruit.com/adding-a-real-time-clock-to-raspberry-pi/set-up-and-test-i2c) provided by Adafruit. This is a quick process that should take less than 10 minutes. Note that an internet connection is required to set the time initially, however after this the time will be held on the clock module.
-
+
## Changing detection settings
-Instructions to change detection settings
+Instructions for changing detection settings
-If you're interested in changing settings there are now two ways to do this:
-1. Using command line flags
-2. Opening the greenonbrown.py file and changing threshold values
-
+Changing detection settings is now easier by using a specific config file.
+
+You can use command line flags to toggle display, data source and setting focus. Use the `config.ini` file in the
+config folder to set other parameters as described below.
+
+The default config file is `DAY_SENSITIVITY_2.ini` (details provided below). If you change any settings here, make sure
+to save the file before restarting `owl.py`.
+
+While we recommend tuning detection parameters to your specific environment, we have provided three sensitivity levels to
+get you started.
+
+| Config File Name | Description |
+|-----------------------|------------------------------------------------------------------------------------------------------------------------------------------------------|
+| DAY_SENSITIVITY_1.ini | The least sensitive parameters, designed to minimise false positive detections to just get the big weeds. Minimum detection size has been increased. |
+| DAY_SENSITIVITY_2.ini | OWL detection parameters were tuned to this file. |
+| DAY_SENSITIVITY_3.ini | The most sensitive parameters. Reduce missed detections with lower minimum detection size and wider detection ranges. |
+
+
### Command line flags
-Command line flags are let you specify options on the command line within the Terminal window. It means you don't have to open up the code and make changes directly. OWL now supports the use of flags for some parameters. To read a description of all flags available type:
+Command line flags are let you specify options on the command line within the Terminal window. It means you don't have
+to open up the code and make changes directly. OWL now supports the use of flags for some parameters. To read a
+description of all flags available type `--help`:
+```commandline
+usage: owl.py [-h] [--input] [--show-display] [--focus]
+ --input path to image directory, single image or video file
+ --show-display show display windows
+ --focus focus the camera
```
-(owl) pi@raspberrypi:~ $./greenonbrown.py --help
-usage: greenonbrown.py [-h] [--video-file VIDEO_FILE] [--show-display] [--recording] [--algorithm {exg,nexg,exgr,maxg,exhsv,hsv}] [--framerate [10-120]]
- [--exposure-mode {off,auto,nightpreview,backlight,spotlight,sports,snow,beach,verylong,fixedfps,antishake,fireworks}]
- [--awb-mode {off,auto,sunlight,cloudy,shade,tungsten,fluorescent,incandescent,flash,horizon}] [--sensor-mode [0-3]]
-optional arguments:
- -h, --help show this help message and exit
- --video-file VIDEO_FILE
- use video file instead
- --show-display show display windows
- --recording record video
- --algorithm {exg,nexg,exgr,maxg,exhsv,hsv}
- --framerate [10-120] set camera framerate between 10 and 120 FPS. Framerate will depend on sensor mode, though setting framerate takes precedence over sensor_mode, For example sensor_mode=0 and framerate=120 will reset the
- sensor_mode to 3.
- --exposure-mode {off,auto,nightpreview,backlight,spotlight,sports,snow,beach,verylong,fixedfps,antishake,fireworks}
- set exposure mode of camera
- --awb-mode {off,auto,sunlight,cloudy,shade,tungsten,fluorescent,incandescent,flash,horizon}
- set the auto white balance mode of the camera
- --sensor-mode [0-3] set the sensor mode for the camera between 0 and 3. Check Raspberry Pi camera documentation for specifics of each mode
+### Creating your own config files
+Feel free to create your own config files to meet your specific conditions. In `owl.py` just update the path to the
+config file. Follow the same layout and format as the default.
+
+To open `owl.py`, you'll need to open it and not execute the file.
+
+Navigate to the `owl` directory | Open the `owl.py` file
+:-------------------------:|:-------------------------:
+ | 
+
+Then scroll down to the very bottom until you find the line below. Update the `config_file=` path to your own config file path.
+
+```python
+owl = Owl(config_file='config/ENTER_YOUR_CONFIG_FILE_HERE.ini')
+```
+
+These are the various system, data collection and detection settings that can be changed. They are defined further below.
+```ini
+[System]
+# select your algorithm
+algorithm = exhsv
+# operate on a video, image or directory of media
+input_file_or_directory =
+# choose how many relays are connected to the OWL
+relay_num = 4
+actuation_duration = 0.15
+delay = 0
+
+[Controller]
+# choose between 'None', 'ute' or 'advanced'
+controller_type = None
+
+# for advanced controller
+detection_mode_pin_up = 35
+detection_mode_pin_down = 36
+recording_pin = 38
+sensitivity_pin = 40
+low_sensitivity_config = config/DAY_SENSITIVITY_2.ini
+high_sensitivity_config = config/DAY_SENSITIVITY_3.ini
+
+# for UteController
+switch_purpose = recording
+switch_pin = 37
+
+[Visualisation]
+image_loop_time = 5
+
+[Camera]
+resolution_width = 640
+resolution_height = 480
+exp_compensation = -2
+
+[GreenOnGreen]
+# parameters related to green-on-green detection
+model_path = models
+confidence = 0.5
+class_filter_id = None
+
+[GreenOnBrown]
+# parameters related to green-on-brown detection
+exg_min = 25
+exg_max = 200
+hue_min = 39
+hue_max = 83
+saturation_min = 50
+saturation_max = 220
+brightness_min = 60
+brightness_max = 190
+min_detection_area = 10
+invert_hue = False
+
+[DataCollection]
+# all data collection related parameters
+# set sample_images True/False to enable/disable image collection
+sample_images = False
+# image collection, sample method include: 'bbox' | 'square' | 'whole'
+sample_method = whole
+sample_frequency = 30
+save_directory = /media/owl/SanDisk
+# set to True to disable weed detection for data collection only
+disable_detection = False
+# log fps
+log_fps = False
+camera_name = cam1
+
+[Relays]
+# defines the relay ID (left) that matches to a boardpin (right) on the Pi.
+# Only change if you rewire/change the relay connections.
+0 = 13
+1 = 15
+2 = 16
+3 = 18
+```
+
+### Parameter definitions
+| **Parameter** | **Options** | **Description** |
+|:-------------------------:|:------------------------------------------------------:|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:|
+| **System** | | |
+| `algorithm` | Any of: `gog`,`exg`,`exgr`,`exgs`,`exhu`,`hsv`,`exhsv` | Changes the selected algorithm. Most sensitive: 'exg', least sensitive/most precise (least false positives): 'exgr', 'exhu', 'hsv', 'exhsv'. |
+| `actuation_duration` | Any float (decimal) | Changes the length of time for which the relay is activated. |
+| `input_file_or_directory` | path to a image, video, or directory of media | Will iterate over each image at a default 5 FPS, or over a directory of images or videos. |
+| `relay_num` | integer | Change the number of activation 'lanes' and therefore the number of relays activated. Set to 1 for a single relay. |
+| `delay` | Any float | Delay between detection and actuation. Defaults to 0. |
+| **Controller** | | |
+| `controller_type` | `'None'`, `'ute'`, `'advanced'` | Specifies the type of controller to use. `'advanced'` enables extra features such as detection mode and sensitivity switching, while `'ute'` is for simpler use cases. `'None'` disables controller. |
+| `detection_mode_pin_up` | Integer | Specifies the GPIO pin for "detection mode up" in advanced controller setups. |
+| `detection_mode_pin_down` | Integer | Specifies the GPIO pin for "detection mode down" in advanced controller setups. |
+| `recording_pin` | Integer | Specifies the GPIO pin to activate/deactivate image recording in advanced controller setups. |
+| `sensitivity_pin` | Integer | Specifies the GPIO pin to switch between low and high sensitivity in advanced controller setups. |
+| `low_sensitivity_config` | Path | Path to the configuration file for low sensitivity settings in advanced controller setups. |
+| `high_sensitivity_config` | Path | Path to the configuration file for high sensitivity settings in advanced controller setups. |
+| `switch_purpose` | 'recording' or other purpose | Describes the purpose of the switch in UteController setups (e.g., activating recording or controlling other functions). |
+| `switch_pin` | Integer | GPIO pin used for switching functionality in UteController setups (e.g., recording activation). |
+| **Visualisation** | | |
+| `image_loop_time` | Integer | How long (ms) to wait on each image when looping over the same image if a single image file or directory is provided. |
+| **Camera** | | |
+| `resolution_width` | Integer | Width of the camera resolution (updated to 640). |
+| `resolution_height` | Integer | Height of the camera resolution (updated to 480). |
+| `exp_compensation` | Integer between -8 and 8 | Change the target exposure setting for the exposure algorithm. Defaults to -2, preferencing darker settings for faster shutter speeds. |
+| **GreenOnGreen** | | |
+| `model_path` | Path | A path to the model file |
+| `confidence` | | The cutoff confidence value for a detection. Defaults to 0.5 or 50%. |
+| `class_filter_id` | Integer | Which classes to filter and target. For example, using a out-the-box COCO model, you may want to only detect a specific class. Enter that specific class integer here. |
+| **GreenOnBrown** | | |
+| `exg_min` | Any integer between 0 and 255 | Provides the minimum threshold value for the exg algorithm. Usually leave between 10 (very sensitive) and 25 (not sensitive) |
+| `exg_max` | Any integer between 0 and 255 | Provides a maximum threshold for the exg algorithm. Leave above 180. |
+| `hue_min` | Any integer between 0 and 128 | Provides a minimum threshold for the hue channel when using hsv or exhsv algorithms. Typically between 39 and 83. |
+| `hue_max` | Any integer between 0 and 128 | Provides a maximum threshold for the hue (colour hue) channel when using hsv or exhsv algorithms. Typically between 39 and 83. |
+| `saturation_min` | Any integer between 0 and 255 | Provides a minimum threshold for the saturation (colour intensity) channel when using hsv or exhsv algorithms. Typically between 50 and 220. |
+| `saturation_max` | Any integer between 0 and 255 | Provides a maximum threshold for the saturation (colour intensity) channel when using hsv or exhsv algorithms. Typically between 50 and 220. |
+| `brightness_min` | Any integer between 0 and 255 | Provides a minimum threshold for the value (brightness) channel when using hsv or exhsv algorithms. Typically between 60 and 190. |
+| `brightness_max` | Any integer between 0 and 255 | Provides a maximum threshold for the value (brightness) channel when using hsv or exhsv algorithms. Typically between 60 and 190. |
+| `min_detection_area` | Integer | The minimum area for which to detect a weed. |
+| `invert_hue` | Boolean | True/False, inverts the detected hue from everything within the thresholds to everything outside the thresholds. |
+| **DataCollection** | | |
+| `sample_images` | Boolean: True or False | Enables or disables image data collection. Defaults to False. Set to True to start collecting images. |
+| `sample_method` | Choose from 'bbox', 'square', 'whole' | If sample_method=None, sampling is deactivated. Do not leave on for long periods or SD card will fill up and stop working. |
+| `sample_frequency` | Any positive integer | Changes how often (after how many frames) image sampling will occur. If sample_frequency=30, images will be sampled every 30 frames. |
+| `save_directory` | Path | Set where you want the images saved. Defaults to `/media/owl/SanDisk`. When sample_images is True, the software will look for an attached USB drive at this location. It will try 5 times before exiting if none are found. |
+| `disable_detection` | Boolean: True or False | Disable detection when running data collection. This will reduce the workload on the Pi and increase frame rate. Useful if using the OWL for dedicated image data collection. |
+| `log_fps` | Boolean: True or False | Save FPS to a file. |
+| `camera_name` | Any string | Changes the save name if recording videos of the camera. Ignore - only used if recording data. |
+| **Relays** | Integer/GPIO Boardpin | Maps a relay number to a boardpin on the GPIO header |
-```
-
-Flag | Usage | Description
-:-------------: | :-------------: | :-------------:
---video-file | Specify the path to the video file. | This is used when a video file is run instead of the live feed from a camera. It is mostly used in testing new algorithms. If this is not included, a connected camera will be used instead.
---show-display | If flag is present, this will return True | When this flag is included, video feeds and threshold adjustments will appear. Without the flag, the OWL will run `headless` with no display. This flag replaces the `Headless=True` variable in the `greenonbrown.py` file.
---algorithm | exg, nexg, exgr, maxg, exhsv, hsv | Select from the list of algorithms to use. Defaults to `exhsv`
---recording | If flag is present, this will return True | Record video to a file
---framerate | between 10 and 120 FPS, default=40 | sets the framerate for the camera.
---exposure-mode | off, auto, nightpreview, backlight, spotlight, sports, snow, beach, verylong, fixedfps, antishake, fireworks | Select from the list of exposure modes available on the [Picamera](https://picamera.readthedocs.io/en/release-1.13/api_camera.html#picamera.PiCamera.exposure_mode). Defaults to 'sports' for faster shutter speed.
---awb-mode | off, auto, sunlight, cloudy, shade, tungsten, fluorescent, incandescent, flash, horizon | set the automatic white balance mode from [Picamera options](https://picamera.readthedocs.io/en/release-1.13/api_camera.html#picamera.PiCamera.awb_mode).
---sensor-mode | 0: default - automatic; modes 1, 2 and 3 are defined in the picamera documentation. | the sensor mode is specific to the camera. The Raspberry Pi v2 camera has 7 modes, whereas the HQ camera has only 4. Framerate is prioritised over sensor mode. WARNING: high framerates and larger resolutions may 'brick' the SD card. Always backup your SD card before testing new settings, or update from this repository if settings are lost.
-
-### Changing threshold values in `greenonbrown.py`
-
-Other parameters such as selecting modifying sensitivity settings can be adjusted in the greenonbrown.py file itself. To edit this file, connect a screen, keyboard and mouse and boot up the OWL. Navigate to the owl directory and open up `greenonbrown.py` in an editor. You'll need to right click, select open with and then choose an integrated development environment (IDE). Once it's open, scroll down to the very bottom and you should come across:
-
-```
-if __name__ == "__main__":
- owl = Owl(videoFile=args.video_file,
- show_display=args.show_display,
- recording=args.recording,
- exgMin=25,
- exgMax=200,
- hueMin=39,
- hueMax=83,
- saturationMin=50,
- saturationMax=220,
- brightnessMin=60,
- brightnessMax=190,
- framerate=args.framerate,
- resolution=(416, 320),
- exposure_mode=args.exposure_mode,
- awb_mode=args.awb_mode,
- sensor_mode=args.sensor_mode)
-
- # start the targeting!
- owl.hoot(sprayDur=0.15,
- delay=0,
- sampleMethod=None,
- sampleFreq=60,
- saveDir='/home/pi/owl-images',
- algorithm=args.algorithm,
- selectorEnabled=False,
- camera_name='hsv',
- minArea=10)
-```
-
-Here's a summary table of what each parameter does. Run `./greenonbrown.py --show-display` to view the output results. Without this `--show-display` flag the video will not appear on the screen.
-
-**NOTE** In older versions ONLY, ff you change the now defunct parameter of `headless` to `False`, you'll be able to see a real time feed of what the algorithm is doing and where the detections are occurring. This will need to be switched back to `headless=True` if you decide to run it without the screen connected. Note that the owl program will not run on startup if `headless=False`.
-
-**Parameter** | **Options** | **Description**
-:-------------: | :-------------: | :-------------:
-**Owl()** | | All options when the sprayer class is instantiated
-`exgMin`|Any integer between 0 and 255| Provides the minimum threshold value for the exg algorithm. Usually leave between 10 (very sensitive) and 25 (not sensitive)
-`exgMax`|Any integer between 0 and 255| Provides a maximum threshold for the exg algorithm. Leave above 180.
-`hueMin`|Any integer between 0 and 128| Provides a minimum threshold for the hue channel when using hsv or exhsv algorithms. Typically between 28 and 45. Increase to reduce sensitivity.
-`hueMax`|Any integer between 0 and 128| Provides a maximum threshold for the hue (colour hue) channel when using hsv or exhsv algorithms. Typically between 80 and 95. Decrease to reduce sensitivity.
-`saturationMin`|Any integer between 0 and 255| Provides a minimum threshold for the saturation (colour intensity) channel when using hsv or exhsv algorithms. Typically between 4 and 20. Increase to reduce sensitivity.
-`saturationMax`|Any integer between 0 and 255| Provides a maximum threshold for the saturation (colour intensity) channel when using hsv or exhsv algorithms. Typically between 200 and 250. Decrease to reduce sensitivity.
-`brightnessMin`|Any integer between 0 and 255| Provides a minimum threshold for the value (brightness) channel when using hsv or exhsv algorithms. Typically between 10 and 60. Increase to reduce sensitivity particularly if false positives in shadows.
-`brightnessMax`|Any integer between 0 and 255| Provides a maximum threshold for the value (brightness) channel when using hsv or exhsv algorithms. Typically between 190 and 250. Decrease to reduce sensitivity particularly if false positives in bright sun.
-`resolution`|Tuple of (w, h) resolution| Changes output resolution from camera. Increasing rapidly decreased framerate but improves detection of small weeds.
-**hoot()** | | All options when the sprayer.start() function is called
-`sprayDur`|Any float (decimal)|Changes the length of time for which the relay is activated.|
-`sampleMethod`|Choose from None, 'bbox', 'square', 'whole' | If sampleMethod=None, sampling is deactivated. Do not leave on for long periods or SD card will fill up and stop working.|
-`sampleFreq` | Any positive integer | Changes how often (after how many frames) image sampling will occur. If sampleFreq=60, images will be sampled every 60 frames. |
-`saveDir` | Path to save directory | Set where you want the images saved. If you insert a USB and would like to save images to it, put the path for that here. |
-`algorithm`|Any of: `exg`,`exgr`,`exgs`,`exhu`,`hsv`| Changes the selected algorithm. Most sensitive: 'exg', least sensitive/most precise (least false positives): 'exgr', 'exhu', 'hsv'|
-`selectorEnabled`|`True` or `False`| Enables algorithm selection based on a rotary switch. Only enable if switch is connected.|
-`cameraName` | Any string | Changes the save name if recording videos of the camera. Ignore - only used if recording data.|
-`minArea`| Any integer | Changes the minimum size of the detection. Leave low for more sensitivity of small weeds and increase to reduce false positives.|
+## Connecting a Controller
+
+
+Adding simple controllers to manage four OWLS or fewer
+
+
+The software and designs for the first generation GPIO-based controllers (between 1 and 4 OWL units) are now available.
+This approach offers a simpler, hardware-based option for managing devices. However, it is more difficult to scale
+and add features. Adressing this issue, a CAN FD-based controller is in development.
+
+| Ute Controller | 'Advanced' Controller |
+|---------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------|
+|  |  |
+
+NOTE: you MUST connect a USB drive when using a controller, otherwise it will not start. The [Sandisk Ultrafit Flash Drive](https://www.amazon.com/SanDisk-256GB-Ultra-Flash-Drive/dp/B07857Y17V)
+will fit within the enclosure.
+
+#### Tools required
+1. Crimping tool for Amphenol crimped connections
+2. Pliers
+3. Wire strippers/cutters
+4. Soldering iron/solder
+
+### Step 1 - Parts list
+The parts lists below are provided as a guide only. Please use local suppliers and search around for items that may have
+equivalent specifications but lower price or faster delivery.
+
+As you increase the number of OWLs connected to the Advanced Controller, be sure to increase the current rating of all
+relevant components. For systems with four OWLs, consider using a relay box (used in cars) to avoid running high current
+cables over long distances and switching them inside a cab.
+
+_Cable protection and sheaths_
+Trailer cable already has a heavy duty outer covering, however a [Nylon Hose Protector](https://www.amazon.com/nylon-hose-sleeve/s?k=nylon+hose+sleeve) sleeve will increase the lifetime of
+cabling, without adding too much expense.
+
+#### Ute Controller
+Suitable for connecting a singular OWL device. TinkerCAD model available [here](https://www.tinkercad.com/things/1s5gWD7wiJv-ute-controller).
+
+| Component | Quantity | Link |
+|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| Car cigarette lighter socket (check fuse rating) | 1 | [Link](https://www.amazon.com/dp/B0BM9LP3R2/ref=sspa_dk_detail_1?pd_rd_i=B0BM9LP3R2&pd_rd_w=USycn&content-id=amzn1.sym.7446a9d1-25fe-4460-b135-a60336bad2c9&pf_rd_p=7446a9d1-25fe-4460-b135-a60336bad2c9&pf_rd_r=Z6F2YG43FCCFN1XV5479&pd_rd_wg=D62Qx&pd_rd_r=4d0e937a-1969-430f-8b76-c845dd46f84c&s=automotive&sp_csd=d2lkZ2V0TmFtZT1zcF9kZXRhaWw&th=1) (make sure the inbuilt fuse matches your system rating) |
+| 7 strand, 3mm (7.5A rated) trailer cable | As needed | [Link](https://perthpro.com.au/products/3mm-or-4mm-7-core-trailer-cable?srsltid=AfmBOopYHIK6cqVkPfnMdo6GWiqwcjVWPSHle8wlZL27G8f9Yzf5uhKn) |
+| M12 Cable gland | 2 | [Link](https://au.mouser.com/ProductDetail/546-1427NCGM12B) |
+| Off-On SPST Toggle Switch | 2 | [Link](https://au.element14.com/arcolectric-bulgin-limited/c3900baaaa/switch-spst-16a-250vac/dp/7674244) |
+| Rubber boot for switch | 2 | [Link](https://au.element14.com/arcolectric/a1080mo/sealing-boot-toggle-15-32-x-32ns/dp/678144) |
+| Amphenol Fathom Lock - 6 (3 + 3) contact straight plug | 1 | [Link](https://au.mouser.com/ProductDetail/Amphenol-SINE-Systems/FLS6BS10N3W3P03?qs=ulEaXIWI0c%2FMtNeYzYmViA%3D%3D) |
+| #16 Contacts (13A rated) | 3 | [Link](https://au.mouser.com/ProductDetail/Amphenol-SINE-Systems/SP16M2F?qs=vLWxofP3U2xkifcKnS1b1w%3D%3D) |
+| #20 Contacts (5A rated) | 3 | [Link](https://au.mouser.com/ProductDetail/Amphenol-SINE-Systems/SP20W2F?qs=vLWxofP3U2xTtXL%252B5wgnlA%3D%3D) |
+| Dialight 12V Green LED Panel Mount | 1 | [Link](https://au.mouser.com/ProductDetail/645-559-0203-001F) |
+| Dialight 5V Red LED Panel Mount | 2 | [Link](https://au.mouser.com/ProductDetail/645-559-0102-001F) |
+| M3 Heatset inserts + M3 screws (Amazon sell [insert kits](https://www.amazon.com/Zuorery-Threaded-Assortment-Printing-Components/dp/B0D1CGC18H/ref=sr_1_33_sspa?brr=1&dib=eyJ2IjoiMSJ9.AiTRZFFImPlKuzH7TjgCHYU6q6MnfDEwNPtZAy4R79tmaLHVvgr6bZr2r-tO5ByHJkNmWRO7h1-wzqkdDdfW8nrggK4_2RE-4XEC2wYOBCtvrqEFHqFvXlvdAHf3jnggMJmqkVTf4CCGTS0t2DRscsNmwr_pEn1h2apyqHfOHEY0-NAJSTggxXG2J1xuU_qr1qI8tdoPrkzft06pz8eRjJtclpqkGZbx3MQmmqVR9nrQiyFYz4y-y_I4VXMuI3Hsv1dQHCSixJ8h2EYzMxN25AXkuJ0b9GUu13aCnXQznz0.sx6qKI2l1q999a7VKXx2nrrRx9oVkHSFni3-ZoWg8l0&dib_tag=se&qid=1730733311&rd=1&s=industrial&sr=1-33-spons&sp_csd=d2lkZ2V0TmFtZT1zcF9idGZfYnJvd3Nl&psc=1)) | 3 + 3 | [Link](https://www.mcmaster.com/products/threaded-inserts/threaded-inserts-2~/heat-set-inserts-for-plastic-7/) |
+
+#### Advanced Controller
+Suitable for connecting up to four OWLs. As the number of OWLs connected increases, each switch should add another pole.
+For four OWLs, you will need four pole switches and an input power cable (and switch) rating of at least 40A @ 12V
+(4 OWLs x 10A).
+
+TinkerCAD models (1, 2, and 4 OWLs) available [here](https://www.tinkercad.com/things/e3JOeuyfLNO-advanced-owl-controller).
+
+| Component | Quantity | Link |
+|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| In-line fuse + holder (10A per OWL connected) | 1 | 30A MAX: [Link](https://www.narva.com.au/products/54406BL/in-line-standard-ats-blade-fuse-holder--blister-pack-of-1-) (make sure the inbuilt fuse matches your system rating)
60A MAX: [Link](https://www.narva.com.au/products/54414/in-line-maxi-blade-fuse-holder--box-of-1-) |
+| 7 strand, 3mm (7.5A rated) trailer cable (used for comms) | As needed | [Link](https://perthpro.com.au/products/3mm-or-4mm-7-core-trailer-cable?srsltid=AfmBOopYHIK6cqVkPfnMdo6GWiqwcjVWPSHle8wlZL27G8f9Yzf5uhKn) |
+| Output power cable (min 10A rating) (used for powering OWL + solenoids) | As needed | [Link](https://www.narva.com.au/products/5823-30TW/10a-3mm-twin-core-sheathed-cable--30m--red-black--black-sheath-) |
+| Input power cable (10A per OWL connected) | As needed | 10A: [Link](https://www.narva.com.au/products/5823-30TW/10a-3mm-twin-core-sheathed-cable--30m--red-black--black-sheath-)
25A: [Link](https://www.narva.com.au/products/5825-30TW/25a-5mm-twin-core-sheathed-cable--30m--red-black--black-sheath-)
50A: [Link](https://www.narva.com.au/products/5826-30TW/50a-6mm-twin-core-sheathed-cable--30m--red-black--black-sheath-) |
+| M16 Cable gland | 1 | [Link](https://au.element14.com/pro-power/m16db/gland-m16-4-8mm-pk10/dp/1621070) |
+| _1 OWL controller_ | | |
+| Off-On SPST Toggle Switch (16A MAX) | 3 | [Link](https://au.element14.com/arcolectric-bulgin-limited/c3900baaaa/switch-spst-16a-250vac/dp/7674244) |
+| On-Off-On SPST Toggle Switch | 1 | [Link](https://au.element14.com/arcolectric/c3920baaaa/switch-spdt-16a-250vac/dp/7674279) |
+| _4 OWL controller_ | | |
+| POWER SWITCH: Off-On SPST Toggle Switch (50A MAX)
NOTE: Consider using a relay bank (e.g. [Relay Box](https://www.amazon.com/dp/B0CP56SXNT/ref=sspa_dk_detail_0?pd_rd_i=B0CP56SXNT&pd_rd_w=ihkmp&content-id=amzn1.sym.386c274b-4bfe-4421-9052-a1a56db557ab&pf_rd_p=386c274b-4bfe-4421-9052-a1a56db557ab&pf_rd_r=MA2472T4GWFKVNW4AFES&pd_rd_wg=USYjj&pd_rd_r=57d62652-d2f3-4570-891e-a10ca388af7f&s=automotive&sp_csd=d2lkZ2V0TmFtZT1zcF9kZXRhaWxfdGhlbWF0aWM&th=1)) instead of running power through the controller | 1 | [Link](https://www.narva.com.au/products/60078BL/off-on-heavy-duty-toggle-switch-with-off-on-tab) |
+| Off-on 4PST toggle switch | 2 | [Link](https://au.mouser.com/ProductDetail/633-S41) |
+| On-Off-On 4PST toggle switch | 1 | [Link](https://au.mouser.com/ProductDetail/633-S43) |
+| _Other parts_ | | |
+| Rubber boot for switch | 4 | [Link](https://au.element14.com/arcolectric/a1080mo/sealing-boot-toggle-15-32-x-32ns/dp/678144) |
+| Amphenol Fathom Lock - 8 contact straight plug | 2 (per OWL connected) | [Link](https://au.element14.com/amphenol-sine-tuchel/fls6bs12n8p03/circular-conn-plug-8pos-crimp/dp/4218774) |
+| Amphenol Fathom Lock - 8 contact receptacle | 1 (add 1 for OWL back cover) | [Link](https://au.element14.com/amphenol-sine-tuchel/fls712n8s03/circular-conn-rcpt-8pos-crimp/dp/4218782) |
+| #16 Contacts (13A rated) (male) | 16 (per OWL connected) | [Link](https://au.mouser.com/ProductDetail/Amphenol-SINE-Systems/SP16M2F?qs=vLWxofP3U2xkifcKnS1b1w%3D%3D) |
+| #16 Contacts (13A rated) (female) | 8 (add 8 for OWL back cover) | [Link](https://au.mouser.com/ProductDetail/Amphenol-SINE-Systems/SS16M2F?qs=vLWxofP3U2z4oc9Ee3zDjw%3D%3D) |
+| Dialight 12V Green LED Panel Mount | 1 | [Link](https://au.mouser.com/ProductDetail/645-559-0203-001F) |
+| Dialight 5V Red LED Panel Mount | 1 (per OWL connected - status light) | [Link](https://au.mouser.com/ProductDetail/645-559-0102-001F) |
+| M3 Heatset inserts + M3 screws (Amazon sell [insert kits](https://www.amazon.com/Zuorery-Threaded-Assortment-Printing-Components/dp/B0D1CGC18H/ref=sr_1_33_sspa?brr=1&dib=eyJ2IjoiMSJ9.AiTRZFFImPlKuzH7TjgCHYU6q6MnfDEwNPtZAy4R79tmaLHVvgr6bZr2r-tO5ByHJkNmWRO7h1-wzqkdDdfW8nrggK4_2RE-4XEC2wYOBCtvrqEFHqFvXlvdAHf3jnggMJmqkVTf4CCGTS0t2DRscsNmwr_pEn1h2apyqHfOHEY0-NAJSTggxXG2J1xuU_qr1qI8tdoPrkzft06pz8eRjJtclpqkGZbx3MQmmqVR9nrQiyFYz4y-y_I4VXMuI3Hsv1dQHCSixJ8h2EYzMxN25AXkuJ0b9GUu13aCnXQznz0.sx6qKI2l1q999a7VKXx2nrrRx9oVkHSFni3-ZoWg8l0&dib_tag=se&qid=1730733311&rd=1&s=industrial&sr=1-33-spons&sp_csd=d2lkZ2V0TmFtZT1zcF9idGZfYnJvd3Nl&psc=1)) | 4 + 4 | [Link](https://www.mcmaster.com/products/threaded-inserts/threaded-inserts-2~/heat-set-inserts-for-plastic-7/) |
+| M5 Heatset inserts + M5 screws for Ram Mount | 4 + 4 | [Link](https://www.mcmaster.com/products/threaded-inserts/threaded-inserts-2~/heat-set-inserts-for-plastic-7/) |
+| Ram Mount Round Plate with C Ball | 1 | [Link](https://rammount.com/products/ram-202u) |
+
+### Step 2 - Download and print the controller enclosure
+3D model files are available for both controller types. These could equally be set up quite easily in widely available
+plastic enclosures available in your local area. It would simply require drilling holes to match switch dimensions.
+Circular connectors and switches have been selected for this reason.
+
+While the Ute Controller is designed to fit in a cupholder, the rear of the Advanced Controller has heat set embedded
+nuts to mount a Ram Mount C-size round plate.
+
+| File | Image | STL (located in 3D models/Controllers) |
+|--------------------------|---|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| Ute Controller Base |  | [Link](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Controllers/Ute%20Controller%20-%20Base.stl) |
+| Ute Controller Top |  | [Link](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Controllers/Ute%20Controller%20-%20Top.stl) |
+| Advanced Controller Base |  | Single: [Link](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Controllers/Advanced%20Controller%20-%20Base%20-%20Single%20OWL.stl)
Double: [Link](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Controllers/Advanced%20Controller%20-%20Base%20-%20Double%20OWL.stl)
4 OWL: [Link](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Controllers/Advanced%20Controller%20-%20Base%20-%204%20OWL.stl) |
+| Advanced Controller Top |  | Single: [Link](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Controllers/Advanced%20Controller%20-%20Top%20-%20Single%20OWL.stl)
Double: [Link](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Controllers/Advanced%20Controller%20-%20Top%20-%20Double%20OWL.stl)
4 OWL: [Link](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Controllers/Advanced%20Controller%20-%20Top%20-%204%20OWL.stl) |
+
+
+### Step 3 - Make connections
+Connections to the switches can be either soldered and covered with heat shrink or connected with automotive-style blade
+fittings (e.g. [these ones](https://dk.farnell.com/en-DK/multicomp/fdfd2-250/crimp-terminal-female-pk100/dp/9971904?gross_price=true&CMP=KNC-GDK-GEN-Shopping-Pmax-High-ROAS-Test-1333&mckv=_dc|pcrid||&gad_source=1&gclid=Cj0KCQiA_qG5BhDTARIsAA0UHSK8XfPiID1ituCt987syg615fI81J2F7KvWkH7X24Wsv7TuShBox9waAh7oEALw_wcB))
+
+LEDs should be carefully soldered and covered with heat shrink.
+
+Crimp connections for the Amphenol Fathomlock connectors are most efficiently and reliably done with a crimping tool, but
+can be crimped with pliers if necessary. Just be very careful to check the quality of the connection by tugging it gently.
+Improperly crimped connections can cause hard to diagnose problems and/or heat up creating a fire risk.
+
+Use the tables below to make the right connections. In both cases, only 6 of the 7 trailer cable wires are used. Cut the 7th
+wire and carefully cover it in heatshrink to avoid it rubbing on the other wires. Alternatively find 6 core cable (7 core
+trailer wire is just much easier to find/cheaper).
+
+Carefully remove the outer protective coating of the trailer cable without damaging the sheaths of the wires inside. If
+you do damage them (it's very easy to do) just use some heat shrink to cover the cut. Remove about 10cm of the outer sheath.
+
+#### Ute controller
+Use one of the M12 glands for the 12V power in from the cigarette socket (or otherwise) and the other for the trailer cable.
+Push the 6 internal wires from the trailer cable through the M12 cable gland and make the following connections:
+1. Connect a black wire to the centre of the REC toggle switch and the shorter pin on the two 5V red LEDs (not the 12V green one).
+Bundle these wires together with a WAGO block or solder and heatshrink them with the BROWN trailer wire. This will be the GPIO GND.
+2. Connect the WHITE wire to the recording/detection toggle switch.
+3. Connect the GREEN wire to the longer pin of the REC red LED.
+4. Connect the YELLOW wire to the longer pin of the MEM LED (storage status indicator)
+
+Push the cigarette light wires through the other M12 gland. Connect the RED wire to the top, power switch. On the other
+leg of the switch solder a 10A rated wire (for the OWL) and a smaller gauge wire for the 12V LED pin. Connect the 10A rated wire from the switch
+to the RED wire from the trailer cable. The 12V black GND wire can be connected directly to the BLACK wire of the trailer
+cable, adding a black smaller gauge wire for the 12V LED.
+
+On the OWL end of the cable, make the following connections using the 6-pin receptacle. Use the connector pin map for the plug end
+of the trailer cable. Take care that the connector pins on the receptacle and the plug match.
+
+| Purpose | Colour | GPIO Pin | Connector Pin (size) |
+|---------------------------------------------|--------|------------------------------------------|-----------------------|
+| Power +12V | Red | N/A - connect to 12V of OWL Driver Board | A (#16) |
+| Power GND | Black | N/A - connect to GND of OWL Driver Board | B (#16) |
+| Recording/Detection Toggle Switch | White | BOARD 37 | C (#16) |
+| Recording status LED (blinks on image save) | Green | BOARD 38 | D (#20) |
+| Storage status indicator | Yellow | BOARD 40 | E (#20) |
+| GPIO GND | Brown | BOARD 39 | F (#20) (centre pin) |
+
+Cover the trailer cable in a nylon sheath if needed.
+
+#### Advanced controller
+The same principles apply for the advanced controller. Power is carried by the separate power wire instead of through the
+trailer cable. A single status LED is needed per OWL for indicating errors, detections and available storage capacity.
+
+Multi-pole toggle switches are used to avoid interference between each of the Raspberry Pi in the OWLs.
+
+Use the table below and connect the correct colour wire with the correct switch. Use a single GPIO GND line for each OWL
+ensuring this is separate to the 12V GND.
+
+| Purpose | Colour | GPIO Pin | Connector Pin (size) |
+|--------------------|-------------------------------|------------------------------------------|----------------------|
+| Power +12V | Red (use additional cable) | N/A - connect to 12V of OWL Driver Board | A (#16) |
+| Power GND | Black (use additional cable) | N/A - connect to GND of OWL Driver Board | B (#16) |
+| All nozzles on | Blue (trailer cable) | BOARD 36 | C (#16) |
+| Recording toggle | Green (trailer cable) | BOARD 38 | D (#16) |
+| Sensitivity toggle | Yellow/Orange (trailer cable) | BOARD 40 | E (#16) |
+| Status LED | White (trailer cable) | BOARD 37 | F (#16) |
+| Spot spray enable | Red (trailer cable) | BOARD 35 | G (#16) |
+| GPIO GND | Brown (trailer cable) | BOARD 39 | H (#16) (centre pin) |
+
+### Step 4 - Add heatset inserts and finish enclosure
+
+Using a soldering iron on a low setting or specifically designed tools, carefully add the M3 and M5 heatset inserts for the
+top cover and the Ram Mount round plate respectively Ensure the inserts are correctly aligned, straight and flush with the
+surface of the controller.
+
+### Step 5 - Update config file
+
+The following parameters are used to change controller settings under the controller section of the `config.ini` file.
+Choose which controller (or 'None') by using None, ute or advanced.
+
+For the Ute Controller, the toggle switch can be used for either toggling recording or detection. Select this mode here.
+
+For the Advanced Controller, the low and high sensitivity files that are switched between with the sensitivity switch can be adjusted
+with paths specified in `low_sensitivity_config` and `high_sensitivity_config`.
+
+```ini
+[Controller]
+# choose between 'None', 'ute' or 'advanced'
+controller_type = None
+
+# for advanced controller
+detection_mode_pin_up = 35
+detection_mode_pin_down = 36
+recording_pin = 38
+sensitivity_pin = 40
+low_sensitivity_config = config/DAY_SENSITIVITY_2.ini
+high_sensitivity_config = config/DAY_SENSITIVITY_3.ini
+
+# for UteController
+switch_purpose = recording
+switch_pin = 37
+```
+NOTE: you MUST connect a USB drive when using a controller, otherwise it will not start.
+
+With the config files set, save them to the OWL, reboot, and you should be ready to go!
+
+
+
+## Green-on-Green
+
+
+How to detect in-crop weeds with the OWL
+
+
+### OWL Integration
+
+Green-on-Green capability is (almost) here!
+
+While we previously had implemented in-crop detection models with the Google Coral and Pycoral, the lack of support
+has made us reconsider that approach. Running detection models like YOLO is in the works to run on the base Raspberry Pi
+5 or with additional hardware such as the Raspberry Pi AI Kit with the Hailo 8L.
+
+If you would like to try the Google Coral, you can by following the instructions in the 'models' directory.
+
+
+### Model Training
+
+Effective models need training data, so if you're interested in using the Green-on-Green functionality, you will need to
+start collecting and annotating images of relevant weeds for training. Alternatively, head over
+to [Weed-AI](https://weed-ai.sydney.edu.au/explore?is_head_filter=%5B%22latest+version%22%5D) to see if any image data
+may be relevant for your purposes.
+
+>⚠️**NOTE**⚠️There do appear to be some issues with the exporting functionality of YOLOv5/v8 to .tflite models for use with
+the Coral. The issue has been raised on the Ultralytics repository and should hopefully be resolved soon. You can follow
+the updates [here](https://github.com/ultralytics/ultralytics/issues/1312).
+
+[YOLOv8](https://github.com/ultralytics/ultralytics) and [YOLOv5](https://github.com/ultralytics/yolov5) currently
+provide the most user friendly methods of training, optimisation and exporting as `.tflite` files for use with the
+Google Coral. There is also a Weed-AI Google Colab
+Notebook
+
+
+which can be used to train models from Weed-AI data directly.
+
+
+
## Non-Raspberry Pi Installation
+
Installing OWL software on a non-Raspberry Pi system
@@ -686,22 +1764,32 @@ Using OWL software on your laptop/desktop or other non-Raspberry Pi system is a
> cd OpenWeedLocator
```
-For the next part, make sure you are in the virtual environment you will be working from. If you're unsure about virtual environments, read through [this PyImageSearch blog](https://pyimagesearch.com/2017/09/25/configuring-ubuntu-for-deep-learning-with-python/) on configuring an Ubuntu environment for deep learning - just skip to the virtual environment step. [FreeCodeCamp](https://www.freecodecamp.org/news/how-to-setup-virtual-environments-in-python/) has a great blog describing them too.
-
+For the next part, make sure you are in the virtual environment you will be working from. If you're unsure about virtual
+environments, read
+through [this PyImageSearch blog](https://pyimagesearch.com/2017/09/25/configuring-ubuntu-for-deep-learning-with-python/)
+on configuring an Ubuntu environment for deep learning - just skip to the virtual environment
+step. [FreeCodeCamp](https://www.freecodecamp.org/news/how-to-setup-virtual-environments-in-python/) has a great blog
+describing them too.
+
Assuming the virtual environment is working and is activated, run through these next couple of steps:
+
```
> pip install -r non_rpi_requirements.txt # this will install all the necessary packages, without including the Raspberry Pi specific ones.
```
-It may take a minute or two for those to complete installing. But once they are done you are free to run the `greenonbrown.py` software.
+It may take a minute or two for those to complete installing. But once they are done you are free to run the `owl.py`
+software.
+
```
-> python greenonbrown.py --show-display
+> python owl.py --show-display
```
-From there you can change the command line flags (as described above) or play around with the settings to see how it works.
+From there you can change the command line flags (as described above) or play around with the settings to see how it
+works.
# Image Processing
+
Image processing details and in-field results
@@ -709,113 +1797,206 @@ So how does OWL actually detect the weeds and trigger the relay control board? I

-Once the green locations are identified and a binary (purely black/white) mask generated, a contouring process is run to outline each detection. If the detection pixel area is greater than the minimum area set in `minArea=10`, the central pixel coordinates of that area are related to an activation zone. That zone is connected to a specific GPIO pin on the Raspberry Pi, itself connected to a specific channel on the relay (one of IN1-4). When the GPIO pin is driven high (activated) the relay switches and connects the solenoid for example to 12V and activates the solenoid. It's all summarised below.
+Once the green locations are identified and a binary (purely black/white) mask generated, a contouring process is run to
+outline each detection. If the detection pixel area is greater than the minimum area set in `minArea=10`, the central
+pixel coordinates of that area are related to an activation zone. That zone is connected to a specific GPIO pin on the
+Raspberry Pi, itself connected to a specific channel on the relay (one of IN1-4). When the GPIO pin is driven high (
+activated) the relay switches and connects the solenoid for example to 12V and activates the solenoid. It's all
+summarised below.

## Results
-The performance of each algorithm on 7 different day/night fields is outlined below. The boxplot shows the range, interquartile range and median performance for each algorithm. Whilst there were no significant differences (P > 0.05) for the recall (how many weeds were detected of all weeds present) and precision (how many detections were actually weeds), trends indicated the ExHSV algorithm was less sensitive (fewer false detections) and more precise, but did miss more smaller/discoloured weeds compared to ExG.
+
+The performance of each algorithm on 7 different day/night fields is outlined below. The boxplot shows the range,
+interquartile range and median performance for each algorithm. Whilst there were no significant differences (P > 0.05)
+for the recall (how many weeds were detected of all weeds present) and precision (how many detections were actually
+weeds), trends indicated the ExHSV algorithm was less sensitive (fewer false detections) and more precise, but did miss
+more smaller/discoloured weeds compared to ExG.

-The image below gives a better indication of the types of weeds that were detected/missed by the ExHSV algorithm. Large, green weeds were consistently found, but small discoloured or grasses with thin leaves that blurred into the background were missed. Faster shutter speed would help improve this performance.
+The image below gives a better indication of the types of weeds that were detected/missed by the ExHSV algorithm. Large,
+green weeds were consistently found, but small discoloured or grasses with thin leaves that blurred into the background
+were missed. Faster shutter speed would help improve this performance.

-
+
# 3D Printing
+
3D printing instructions and files
-There are seven total items that need printing for the complete OWL unit. All items with links to the STL files are listed below. There are two options for OWL base:
+
+## Original OWL
+There are seven total items that need printing for the Original OWL unit. All items with links to the STL files are
+listed below. There are two options for Original OWL base:
1. Single connector (Bulgin) panel mount
- - Pros: of this method are easy/quick attach/detach from whatever you have connected, more water resistant.
- - Cons: more connections to make, more expensive
+ - Pros: of this method are easy/quick attach/detach from whatever you have connected, more water resistant.
+ - Cons: more connections to make, more expensive
2. Cable gland
- - Pros: fewer connections to make, cheaper, faster to build.
- - Cons: more difficult to remove, more water resistant.
+ - Pros: fewer connections to make, cheaper, faster to build.
+ - Cons: more difficult to remove, less water resistant.
+
+We also provide a link to the [3D models on TinkerCAD](https://www.tinkercad.com/things/fhfUCsPEn5q), an online and free
+3D modelling software package, allowing for further customisation to cater for individual user needs.
+
+| Description | Image (click for link) |
+|:-------------------------------------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:|
+| **Original OWL** | |
+| OWL base, onto which all components are mounted. The unit can be fitted using the M6 bolt holes on the rear panel. | [](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Original%20OWL/Enclosure%20-%20single%20connector.stl) |
+| OPTIONAL: OWL base with cable glands instead of single Bulgin connector. | [](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Original%20OWL/Enclosure%20-%20cable%20gland.stl) |
+| OWL cover, slides over the base and is fitted with 4 x M3 bolts/nuts. Provides basic splash protection. | [](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Original%20OWL/Tall%20enclosure%20cover.stl) |
+| OWL base port cover, covers the cable port on the rear panel. | [](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Original%20OWL/Tall%20enclosure%20plug.stl) |
+| Raspberry Pi mount, fixes to the Raspberry Pi for easy attachment to OWL base. | [](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Original%20OWL/Raspberry%20Pi%20mount.stl) |
+| Raspberry Pi Camera mount, fixes to the HQ or V2 Camera for simple attachment to the base. | [](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Original%20OWL/Camera%20mount.stl) |
+| Relay board mount, fixes to the relay board for simple attachment to the base. | [](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Original%20OWL/Relay%20control%20board%20mount.stl) |
+| Voltage regulator mount, fixes to the voltage regulator and onto the relay board for simple attachment to the base. | [](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Original%20OWL/Voltage%20regulator%20mount.stl) |
+
+Ideally supports should be used for the base, and were tested at 0.2mm layer heights with 25% infill on a Prusa MK3S.
-
-We also provide a link to the [3D models on Tinkercad](https://www.tinkercad.com/things/fhfUCsPEn5q), an online and free 3D modelling software package, allowing for further customisation to cater for individual user needs.
-
-Description | Image (click for link)
-:-------------------------:|:-------------------------:
-OWL base, onto which all components are mounted. The unit can be fitted using the M6 bolt holes on the rear panel. | [](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Enclosure%20-%20single%20connector.stl)
-OPTIONAL: OWL base with cable glands instead of single Bulgin connector. | [](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Enclosure%20-%20cable%20gland.stl)
-OWL cover, slides over the base and is fitted with 4 x M3 bolts/nuts. Provides basic splash protection. | [](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Tall%20enclosure%20cover.stl)
-OWL base port cover, covers the cable port on the rear panel. | [](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Tall%20enclosure%20plug.stl)
-Raspberry Pi mount, fixes to the Raspberry Pi for easy attachment to OWL base. | [](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Raspberry%20Pi%20mount.stl)
-Raspberry Pi Camera mount, fixes to the HQ or V2 Camera for simple attachment to the base. | [](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Camera%20mount.stl)
-Relay board mount, fixes to the relay board for simple attachment to the base. | [](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Relay%20control%20board%20mount.stl)
-Voltage regulator mount, fixes to the voltage regulator and onto the relay board for simple attachment to the base. | [](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Voltage%20regulator%20mount.stl)
-
-All .stl files for the 3D printed components of this build are available in the 3D Models directory. Ideally supports should be used for the base, and were tested at 0.2mm layer heights with 15% infill on a Prusa MK3S.
-
**Update 02/05/2022**
+
* improved camera mounts
* space for 40mm lens cover
* more compact design
* version tracking
+*
+## Compact OWL
+The Compact OWL has fewer parts to print than the Original OWL and is both more durable and water resistant. A complete
+unit requires printing of only 5 parts.
+
+All 3D model files are availabe on to edit and download on [TinkerCAD](https://www.tinkercad.com/things/id1FMJrWtJp-compact-owl) or [Printables](https://www.printables.com/model/875853-raspberry-pi-rugged-imaging-enclosure).
+The 3D printing .stl files are provided under the 3D Models and through the links in the table below.
+
+The backplate comes in three options:
+1. Amphenol EcoMate Aquarius receptacle only
+2. Amphenol EcoMate Aquarius receptacle + Adafruit RJ45 waterproof ethernet connector
+3. 16mm cable gland only
+
+Pick one of these backplates when printing.
+
+| Description | Image (click for link) |
+|:-------------------------------------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:|
+| **Compact OWL** | |
+| Enclosure body: houses all components on tray | [](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Compact%20OWL/Main%20Body.stl) |
+| Frontplate: covers the front of the enclosure. Incorporates a 37 mm lens cover to seal the enclosure. | [](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Compact%20OWL/Frontplate.stl) |
+| Lens mount: Securely mounts the 37 mm UV lens filter to the frontplate. | [](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Compact%20OWL/Lens%20Mount.stl) |
+| Backplate: 1 x Amphenol EcoMate Aquarius Receptacle size 10 shell, 1 x size 12 shell| [](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Compact%20OWL/Backplate%20-%202%20x%20Amphenol%20receptacle.stl) |
+| Backplate: 1 x Amphenol EcoMate Aquarius Receptacle | [](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Compact%20OWL/Backplate%20-%20receptacle%20only.stl) |
+| Backplate: 1 x Amphenol EcoMate Aquarius Receptacle, 1 x Adafruit Ethernet Connector | [](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Compact%20OWL/Backplate%20-%20receptacle%20and%20ethernet.stl) |
+| Backplate: blank | [](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Compact%20OWL/Backplate%20-%20blank.stl) |
+| Backplate: 1 x 16mm Cable Gland | [](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Compact%20OWL/Backplate%20-%20gland.stl) |
+| Tray: Mounts all required hardware and fits into the enclosure body on the second rail. Use M3 heat-set threaded inserts for the lens holder. | [](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Compact%20OWL/Tray%20-%20base.stl) |
+| Lens holder: Secures the lens with two M3 x 6mm screws. | [](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Compact%20OWL/Tray%20-%20lens%20holder.stl) |
+| OPTIONAL: Camera Module 3/V2 Camera mounting plate | [](https://github.com/geezacoleman/OpenWeedLocator/blob/main/3D%20Models/Compact%20OWL/Camera%20Mount.stl) |
+
+All .stl files for the 3D printed components of this build are available in the 3D Models directory.
+Supports are not required but do improve print quality. All parts were printed with a Bambu Labs P1S at 0.16mm layer height
+at 25% infill.
-
+
# Updating OWL
+
Updating OWL software
-We and others will be continually contributing to and improving OWL as we become aware of issues or opportunities to increase detection performance. Once you have a functioning setup the process to update is simple. First, you'll need to connect a screen, keyboard and mouse to the OWL unit and boot it up. Navigate to the existing owl directory in `/home/owl/` and either delete or rename that folder. Remember if you've made any of your own changes to the parameters/code, write them down. Then open up a Terminal window (Ctrl + T) and follow these steps:
+We and others will be continually contributing to and improving OWL as we become aware of issues or opportunities to
+increase detection performance. Once you have a functioning setup the process to update is simple with a single bash script.
+First, you'll either need to connect a screen, keyboard and mouse to the OWL unit or do this via SSH.
-**IMPORTANT**: Before continuing make sure you are in the `owl` virtual environment. Check that `(owl)` appears at the start of each command line, e.g. `(owl) pi@raspberrypi:~ $`. Run `workon owl` if you are unsure. If you are not in the `owl` environment, you will run into errors when starting `greenonbrown.py`.
+>⚠️**IMPORTANT**⚠️ Before continuing make sure you are NOT in the `owl` directory: e.g. `(owl) owl@raspberrypi:~. You can
+> double check by running `~``
+The software can be updated using a one line bash script:
```
-(owl) pi@raspberrypi:~ $ cd ~
-(owl) pi@raspberrypi:~ $ mv owl owl-old # this renames the old 'owl' folder to 'owl-old'
-(owl) pi@raspberrypi:~ $ git clone https://github.com/geezacoleman/OpenWeedLocator # download the new software
-(owl) pi@raspberrypi:~ $ mv OpenWeedLocator owl # rename the download to 'owl'
-(owl) pi@raspberrypi:~ $ cd ~/owl
-(owl) pi@raspberrypi:~/owl $ pip install -r requirements.txt
-(owl) pi@raspberrypi:~/owl $ chmod a+x greenonbrown.py
-(owl) pi@raspberrypi:~/owl $ chmod a+x owl_boot.sh
+bash ~/owl/owl_update.sh
```
And that's it! You're good to go with the latest software.
-If you have multiple units running, the most efficient method is to update one and then copy the SD card disk image to every other unit. Follow these instructions here. ADD INSTRUCTIONS
+If you have multiple units running, the most efficient method is to update one and then copy the SD card disk image to
+every other unit. Follow the instructions presented here, to use software already installed on the Pi.
## Version History
+
All versions of OWL can be found here. Only major changes will be recorded as separate disk images for use.
-Version | File
-:-------------------------:|:-------------------------:
-v1.0.0-owl.img | https://www.dropbox.com/s/ad6uieyk3awav9k/owl.img.zip?dl=0
+| Version | File | Raspbian |
+|:---------------:|:----------------------------------------------------------------------:|:--------------------:|
+| v1.0.0-owl.img | [Download](https://www.dropbox.com/s/ad6uieyk3awav9k/owl.img.zip?dl=0) | Buster (picamera) |
+| v2.0.0-owl.img | [Download]() | Bookworm (picamera2) |
+
-
+
# Troubleshooting
+
Troubleshooting OWL issues
+## Software
+
+The table below includes a summary of all current error classes and their hierarchy. All errors include detailed logging, color-formatted terminal output, and standardized error handling. When encountering an error, check the terminal output for specific guidance and follow the suggested solutions for your specific error.
+
+If an error appears that isn't caught here, please raise an issue and we'll update this table and the `error_manager.py` file.
+
+| Error Class | Subclasses / Specific Errors | Common Causes | Solutions |
+|----------------------------|---------------------------------------|------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------|
+| **`OWLError`** | Base exception for all OWL-related errors | — | — |
+| | **`CameraNotFoundError`** | - Disconnected camera
- Damaged ribbon cable
- Camera not enabled | 1. Check camera connections
2. Run `vcgencmd get_camera`
3. Enable camera in raspi-config |
+| | **`OWLProcessError`** | — | — |
+| | - `OWLAlreadyRunningError` | - Another OWL instance active
- GPIO pins in use | 1. Use `kill ` to stop process
2. Check for zombie processes
3. Reboot if persists |
+| **`StorageError`** | Base class for storage-related errors | — | — |
+| | **`USBError`** | — | — |
+| | - `USBMountError` | • Device not properly mounted
• Permission issues | 1. Check physical connection
2. Verify mount points
3. Check device permissions |
+| | - `USBWriteError` | • Write-protected device
• Full storage
• Permission issues | 1. Check write protection
2. Verify available space
3. Check file permissions |
+| | - `NoWritableUSBError` | • No USB storage detected
• All devices write-protected | 1. Connect USB device
2. Check write protection
3. Format device if needed |
+| | **`StorageSystemError`** | - Incompatible platform for storage operation | 1. Use Linux/Raspberry Pi
2. Specify a valid local directory in config
3. Use `--save-directory` flag |
+| **`OWLControllerError`** | Base class for controller-related errors | — | — |
+| | - `ControllerPinError` | - Pin conflicts
- Invalid pin numbers
- Hardware issues | 1. Check pin configurations
2. Verify physical connections
3. Check for conflicts |
+| | - `ControllerConfigError` | - Missing or invalid configuration | 1. Check config file
2. Add missing settings
3. Ensure values are appropriate |
+| **`OWLConfigError`** | Base class for configuration errors | — | — |
+| | - `ConfigFileError` | - Missing config file
- Invalid file path
- Corrupt file | 1. Verify file exists
2. Check file permissions
3. Validate file contents |
+| | - `ConfigSectionError` | - Missing required sections in config file | 1. Add missing sections to the config file |
+| | - `ConfigKeyError` | - Missing required keys in a section | 1. Add missing keys to the respective config section |
+| | - `ConfigValueError` | - Invalid configuration values | 1. Correct values to fit expected ranges |
+| **`AlgorithmError`** | Base class for algorithm-related errors | - Missing dependencies
- Invalid model files
- Coral device issues | 1. Install required packages
2. Verify model files
3. Check Coral device connection |
+| **`OpenCVError`** | — | - OpenCV not installed
- Wrong virtual environment | 1. Run `workon owl`
2. Install opencv-python
3. Run owl_setup.sh |
+| **`DependencyError`** | — | - Missing Python packages
- Wrong virtual environment | 1. Activate owl environment
2. Run pip install for package
3. Install from requirements.txt |
+
+## Hardware
+
+Here's a table of some of the common symptoms and possible explanations for errors we've come across. This is by no
+means exhaustive, but hopefully helps in diagnosing any issues you might have. If you come across any others please
+contact us so we can improve the software, hardware and guide.
+
+>⚠️**NOTE**⚠️ If you are using the original disk image without updating, there are a number of issues that will appear. We
+recommend updating to the latest software by following the procedure detailed in the [Updating OWL](#updating-owl)
+section above.
+
+| Symptom | Explanation | Possible solution |
+|:-----------------------------------------------------------:|:----------------------------------------------------------------------------------------------------:|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:|
+| Raspberry Pi won't start (no green/red lights) | No power getting to the computer | Check the power source, and all downstream components. Such as Bulgin panel/plug connections fuse connections and fuse, connections to Wago 2-way block, voltage regulator connections, cable into the Raspberry Pi. |
+| Raspberry Pi starts (green light flashing) but no beep | OWL software has not started | This is likely a configuration/camera connection error with many possible causes. To get more information, boot the Raspberry Pi with a screen connected, open up a Terminal window (Ctrl + T) and type `~/owl/./owl.py`. This will run the program. Check any errors that emerge. |
+| Beep heard, but no relays activating when tested with green | Relays are not receiving (1) 12V power, (2) a signal from the Pi, (3) the Pi is not sending a signal | Check all your connections with a multimeter if necessary for the presence of 12V. Make sure everything is connected as per the wiring diagram. If you're confident there are no connection issues, open up a Terminal window (Ctrl + T) and type `~/owl/./owl.py`. This will run the program. Check any errors that emerge. |
-Here's a table of some of the common symptoms and possible explanations for errors we've come across. This is by no means exhaustive, but hopefully helps in diagnosing any issues you might have. If you come across any others please contact us so we can improve the software, hardware and guide.
-
-**NOTE** If you are using the original disk image without updating, there are a number of issues that will appear. We recommend updating to the latest software by following the procedure detailed in the [Updating OWL](#updating-owl) section above.
-
-Symptom | Explanation | Possible solution
-:-------------------------:|:-------------------------:|:-------------------------:
-Raspberry Pi won't start (no green/red lights) | No power getting to the computer | Check the power source, and all downstream components. Such as Bulgin panel/plug connections fuse connections and fuse, connections to Wago 2-way block, voltage regulator connections, cable into the Raspberry Pi.
-Raspberry Pi starts (green light flashing) but no beep | OWL software has not started | This is likely a configuration/camera connection error with many possible causes. To get more information, boot the Raspberry Pi with a screen connected, open up a Terminal window (Ctrl + T) and type `~/owl/./greenonbrown.py`. This will run the program. Check any errors that emerge.
-Beep heard, but no relays activating when tested with green | Relays are not receiving (1) 12V power, (2) a signal from the Pi, (3) the Pi is not sending a signal | Check all your connections with a multimeter if necessary for the presence of 12V. Make sure everything is connected as per the wiring diagram. If you're confident there are no connection issues, open up a Terminal window (Ctrl + T) and type `~/owl/./greenonbrown.py`. This will run the program. Check any errors that emerge.
# Citing OWL
+
Citing OWL
-OpenWeedLocator has been published in [Scientific Reports](https://www.nature.com/articles/s41598-021-03858-9). Please consider citing the published article using the details below.
+OpenWeedLocator has been published in [Scientific Reports](https://www.nature.com/articles/s41598-021-03858-9). Please
+consider citing the published article using the details below.
+
```
@article{Coleman2022,
author = {Coleman, Guy and Salter, William and Walsh, Michael},
@@ -831,23 +2012,32 @@ year = {2022}
}
```
+
# Acknowledgements
+
Acknowledgements
-This project has been developed by Guy Coleman and William Salter at the University of Sydney, Precision Weed Control Lab. It was supported and funded by the Grains Research and Development Corporation (GRDC) and Landcare Australia as part of the University of Sydney's Digifarm project in Narrabri, NSW, Australia. We would like to thank all the farmers that assisted in data collection, validation and feedback on the initial design.
+This project has been developed by Guy Coleman and William Salter at the University of Sydney, Precision Weed Control
+Lab. It was supported and funded by the Grains Research and Development Corporation (GRDC) and Landcare Australia as
+part of the University of Sydney's Digifarm project in Narrabri, NSW, Australia. We would like to thank all the farmers
+that assisted in data collection, validation and feedback on the initial design.
# Disclaimer and License
+
Disclaimer and License
-While every effort has been made in the development of this guide to cover critical details, it is not an exhaustive nor perfectly complete set of instructions. It is important that people using this guide take all due care in assembly to avoid damage, loss of components and personal injury, and are supervised by someone experienced if necessary. Assembly and use of OWL is entirely at your own risk and the license expressly states there is no warranty.
+While every effort has been made in the development of this guide to cover critical details, it is not an exhaustive nor
+perfectly complete set of instructions. It is important that people using this guide take all due care in assembly to
+avoid damage, loss of components and personal injury, and are supervised by someone experienced if necessary. Assembly
+and use of OWL is entirely at your own risk and the license expressly states there is no warranty.
```
MIT License
@@ -872,15 +2062,19 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
+
-
+
# References
+
References
-
+
**Journal Papers**
-Woebbecke, D. M., Meyer, G. E., Von Bargen, K., Mortensen, D. A., Bargen, K. Von, and Mortensen, D. A. (1995). Color Indices for Weed Identification Under Various Soil, Residue, and Lighting Conditions. Trans. ASAE 38, 259–269. doi:https://doi.org/10.13031/2013.27838.
+Woebbecke, D. M., Meyer, G. E., Von Bargen, K., Mortensen, D. A., Bargen, K. Von, and Mortensen, D. A. (1995). Color
+Indices for Weed Identification Under Various Soil, Residue, and Lighting Conditions. Trans. ASAE 38, 259–269.
+doi:https://doi.org/10.13031/2013.27838.
**Blog Posts**
[How to run a Raspberry Pi script at startup](https://www.makeuseof.com/how-to-run-a-raspberry-pi-program-script-at-startup/)
@@ -890,5 +2084,13 @@ Woebbecke, D. M., Meyer, G. E., Von Bargen, K., Mortensen, D. A., Bargen, K. Von
[Install OpenCV 4 on Raspberry Pi 4 and Raspbian Buster](https://www.pyimagesearch.com/2019/09/16/install-opencv-4-on-raspberry-pi-4-and-raspbian-buster/)
[How to solder](https://www.makerspaces.com/how-to-solder/)
-
+
+
+# Repository Stats
+
+### Star History
+
+[](https://star-history.com/#geezacoleman/OpenWeedLocator&Timeline)
+
+
diff --git a/algorithms.py b/algorithms.py
deleted file mode 100644
index 69af95e..0000000
--- a/algorithms.py
+++ /dev/null
@@ -1,229 +0,0 @@
-import numpy as np
-import cv2
-
-### Adding a new algorithm ###
-"""
-To add a new algorithm the only requirement is that it accepts a BGR (opencv) image and returns a grayscale
-image as an output. If it returns a binary image (like hsv) then it must return a boolean True in addition to the image
-as it has already been thresholded.
-"""
-##############################
-
-def exg(image):
- """
- Takes an image and processes it using ExG. Returns a single channel exG output.
- Developed by Woebbecke et al. 1995.
- :return: grayscale image
- """
- # using array slicing to split into channels
- blue = image[:, :, 0].astype(np.float32)
- green = image[:, :, 1].astype(np.float32)
- red = image[:, :, 2].astype(np.float32)
- # cv2.imshow('blue', blue.astype('uint8'))
- # cv2.imshow('green', green.astype('uint8'))
- # cv2.imshow('red', red.astype('uint8'))
-
- imgOut = 2 * green - red - blue
- imgOut = np.clip(imgOut, 0, 255)
- imgOut = imgOut.astype('uint8')
-
- # cv2.imshow('ExG', imgOut)
- return imgOut
-
-def maxg(image):
- '''
- Takes an input image in int8 format and calculates the 'maxg' algorithm based on the following publication:
- 'Weed Identification Using Deep Learning and Image Processing in Vegetable Plantation', Jin et al. 2021
- :param image: 8 bit input image in BGR format (opencv)
- :return: grayscale image
- '''
- # using array slicing to split into channels with float32 for calculation
- blue = image[:, :, 0].astype(np.float32)
- green = image[:, :, 1].astype(np.float32)
- red = image[:, :, 2].astype(np.float32)
-
- imgOut = 24 * green - 19 * red - 2 * blue
- imgOut = (imgOut / np.amax(imgOut)) * 255 # scale image between 0 - 255
- imgOut = imgOut.astype('uint8')
-
- return imgOut
-
-def exg_standardised(image):
- '''
- Takes an input image in int8 format and calculates the standardised ExG algorithm
- :param image: int8 image (opencv)
- :return: returns a grayscale image
- '''
- blue = image[:, :, 0].astype(np.float32)
- green = image[:, :, 1].astype(np.float32)
- red = image[:, :, 2].astype(np.float32)
- chanSum = red + green + blue
- chanSum = np.where(chanSum == 0, 1, chanSum)
-
- b = blue / chanSum
- g = green / chanSum
- r = red / chanSum
-
- imgOut = 255 * (2 * g - r - b)
- imgOut = np.where(imgOut < 0, 0, imgOut)
- imgOut = np.where(imgOut > 255, 255, imgOut)
-
- imgOut = imgOut.astype('uint8')
- # cv2.imshow('ExG Standardised', imgOut)
-
- return imgOut
-
-def exg_standardised_hue(image, hueMin=30, hueMax=90, brightnessMin=10, brightnessMax=220, saturationMin=30, saturationMax=255):
- '''
- Takes an image and performs a combined ExG + HSV algorithm
- :param image: input image
- :param hueMin: minimum hue value
- :param hueMax: maximum hue value
- :param brightnessMin: minimum 'value' or brightness value
- :param brightnessMax: maximum
- :param saturationMin: minimum saturation
- :param saturationMax: maximum saturation
- :return: returns a grayscale image
- '''
- blue = image[:, :, 0].astype(np.float32)
- green = image[:, :, 1].astype(np.float32)
- red = image[:, :, 2].astype(np.float32)
-
- chanSum = red + green + blue
- chanSum = np.where(chanSum == 0, 1, chanSum)
-
- b = blue / chanSum
- g = green / chanSum
- r = red / chanSum
-
- imgOut = 255 * (2 * g - r - b)
- imgOut = np.where(imgOut < 0, 0, imgOut)
- imgOut = np.where(imgOut > 255, 255, imgOut)
-
- imgOut = imgOut.astype('uint8')
-
- hsvThresh, _ = hsv(image,
- hueMin=hueMin, hueMax=hueMax,
- brightnessMin=brightnessMin, brightnessMax=brightnessMax,
- saturationMin=saturationMin, saturationMax=saturationMax)
- imgOut = hsvThresh & imgOut
- # cv2.imshow('exhu', imgOut)
-
- return imgOut
-
-def exgr(image):
- '''
- performs the ExGR algorithm on the input image
- :param image: input image
- :return: returns a grayscale image
- '''
- green = image[:, :, 1].astype(np.float32)
- red = image[:, :, 2].astype(np.float32)
-
- exgImg = exg(image)
- imgOut = exgImg - (1.4 * red - green)
-
- imgOut = np.clip(imgOut, 0, 255)
- imgOut = imgOut.astype('uint8')
-
- return imgOut
-
-def hsv(image, hueMin=30, hueMax=90, brightnessMin=10, brightnessMax=220, saturationMin=30, saturationMax=255):
- '''
- Performs an HSV thresholding operation on the input image
- :param image:
- :param hueMin:
- :param hueMax:
- :param brightnessMin:
- :param brightnessMax:
- :param saturationMin:
- :param saturationMax:
- :return: returns a binary image and boolean thresholded or not
- '''
- image = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
- hue = image[:, :, 0]
- sat = image[:, :, 1]
- val = image[:, :, 2]
- # cv2.imshow('hue', hue)
- # cv2.imshow('sat', sat)
- # cv2.imshow('val', val)
-
- hueThresh = cv2.inRange(hue, hueMin, hueMax)
- satThresh = cv2.inRange(sat, saturationMin, saturationMax)
- valThresh = cv2.inRange(val, brightnessMin, brightnessMax)
-
- outThresh = satThresh & valThresh & hueThresh
- # cv2.imshow('HSV Out', outThresh)
- return outThresh, True
-
-# for NIR images only
-def gndvi(image):
- """
- Takes an image and processes it using GNDVI. Returns a single channel grayscale scaled output.
- :return:
- """
- # using array slicing to split into channel
- green = image[:, :, 1].astype(np.float32)
- NIR = image[:, :, 2].astype(np.float32)
-
- imgOut = (NIR - green) / (NIR + green)
- imgOut = cv2.normalize(imgOut, None, alpha=0, beta=255, norm_type=cv2.NORM_MINMAX, dtype=cv2.CV_32F)
- imgOut = imgOut.astype('uint8')
- cv2.imshow('gndvi', imgOut)
- return imgOut
-
-
-# Other vegetation indices are listed here, but have NOT been tested.
-def veg(image):
- blue = image[:, :, 0].astype(np.float32)
- green = image[:, :, 1].astype(np.float32)
- red = image[:, :, 2].astype(np.float32)
-
- imgOut = green / ((red ** 0.667) * (blue ** 0.333))
- imgOut = cv2.normalize(imgOut, None, alpha=0, beta=255, norm_type=cv2.NORM_MINMAX, dtype=cv2.CV_32F)
- imgOut = np.clip(imgOut, 0, 255)
- imgOut = imgOut.astype('uint8')
-
- return imgOut
-
-def cive(image):
- blue = image[:, :, 0].astype(np.float32)
- green = image[:, :, 1].astype(np.float32)
- red = image[:, :, 2].astype(np.float32)
-
- imgOut = 0.441 * red - 0.881 * green + 0.385 * blue + 18.78745
- #imgOut = cv2.normalize(imgOut, None, alpha=0, beta=255, norm_type=cv2.NORM_MINMAX, dtype=cv2.CV_32F)
- imgOut = np.clip(imgOut, 0, 255)
- imgOut = imgOut.astype('uint8')
-
- return imgOut
-
-def clahe_sat_val(image):
- image = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
- hue = image[:, :, 0]
- sat = image[:, :, 1]
- val = image[:, :, 2]
-
- clahe = cv2.createCLAHE(clipLimit=20, tileGridSize=(64,64))
- satCL = clahe.apply(sat)
- valCL = clahe.apply(val)
-
- claheImage = cv2.merge([hue, satCL, valCL])
- claheImage = cv2.cvtColor(claheImage, cv2.COLOR_HSV2BGR)
- #cv2.imshow('CLAHE', claheImage)
- return claheImage
-
-def dgci(image):
- image = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
-
- hue = image[:, :, 0].astype(np.float32)
- sat = image[:, :, 1].astype(np.float32)
- val = image[:, :, 2].astype(np.float32)
-
- np.seterr(divide='ignore', invalid='ignore')
- imgOut = ((hue - 60)/(60 + (1 - sat) + (1 - val)))/3
-
- imgOut = imgOut.astype('uint8')
- imgOut = cv2.normalize(imgOut, None, alpha=0, beta=255, norm_type=cv2.NORM_MINMAX, dtype=cv2.CV_32F)
-
- return imgOut
\ No newline at end of file
diff --git a/button_inputs.py b/button_inputs.py
deleted file mode 100644
index c8d640b..0000000
--- a/button_inputs.py
+++ /dev/null
@@ -1,76 +0,0 @@
-import time
-
-import platform
-# check if the system is being tested on a Windows or Linux x86 64 bit machine
-if platform.system() == "Windows":
- testing = True
-else:
- if '64' in platform.machine():
- testing = True
- else:
- from gpiozero import Button, LED
- testing = False
-
-
-class SensitivitySelector:
- def __init__(self, switchDict: dict):
- self.switchDict = switchDict
- self.buttonList = []
-
- for sensitivityList, GPIOpin in self.switchDict.items():
- button = Button("BOARD{}".format(GPIOpin))
- self.buttonList.append([button, sensitivityList])
-
- def sensitivity_selector(self):
- pass
-
-# used with a physical dial to select the algorithm during initial validation.
-# No longer used in the main greenonbrown.py file
-class Selector:
- def __init__(self, switchDict: dict):
- self.switchDict = switchDict
- self.buttonList = []
-
- for algorithm, GPIOpin in self.switchDict.items():
- button = Button("BOARD{}".format(GPIOpin))
- self.buttonList.append([button, algorithm])
-
- def algorithm_selector(self, algorithm):
- for button in self.buttonList:
- if button[0].is_pressed:
- if algorithm == button[1]:
- return button[1], False
-
- return button[1], True
-
- return 'exg', False
-
-# video recording button
-class Recorder:
- def __init__(self, recordGPIO: int):
- self.recordButton = Button("BOARD{}".format(recordGPIO))
- self.record = False
- self.saveRecording = False
- self.running = True
- self.led = LED(pin='BOARD38')
-
- self.recordButton.when_pressed = self.start_recording
- self.recordButton.when_released = self.stop_recording
-
- def button_check(self):
- while self.running:
- self.recordButton.when_pressed = self.start_recording
- self.recordButton.when_released = self.stop_recording
- time.sleep(1)
-
- def start_recording(self):
- self.record = True
- self.saveRecording = False
- self.led.on()
-
- def stop_recording(self):
- self.saveRecording = True
- self.record = False
- self.led.off()
-
-
diff --git a/config/DAY_SENSITIVITY_1.ini b/config/DAY_SENSITIVITY_1.ini
new file mode 100644
index 0000000..2c1eee5
--- /dev/null
+++ b/config/DAY_SENSITIVITY_1.ini
@@ -0,0 +1,76 @@
+[System]
+# select your algorithm
+algorithm = exhsv
+# operate on a video, image or directory of media
+input_file_or_directory =
+# choose how many relays are connected to the OWL
+relay_num = 4
+actuation_duration = 0.15
+delay = 0
+
+[Controller]
+# choose between 'none', 'ute' or 'advanced' - avoid using '' or "". Just plain text only: none or ute or advanced
+controller_type = none
+
+# for advanced controller
+detection_mode_pin_up = 35
+detection_mode_pin_down = 36
+recording_pin = 38
+sensitivity_pin = 40
+low_sensitivity_config = config/DAY_SENSITIVITY_1.ini
+high_sensitivity_config = config/DAY_SENSITIVITY_3.ini
+
+# for UteController
+switch_purpose = recording
+switch_pin = 37
+
+[Visualisation]
+image_loop_time = 5
+
+[Camera]
+resolution_width = 416
+resolution_height = 320
+exp_compensation = -2
+
+[GreenOnGreen]
+# parameters related to green-on-green detection
+model_path = models
+confidence = 0.5
+class_filter_id = None
+
+[GreenOnBrown]
+# parameters related to green-on-brown detection
+exg_min = 25
+exg_max = 200
+hue_min = 41
+hue_max = 80
+saturation_min = 52
+saturation_max = 218
+brightness_min = 62
+brightness_max = 188
+min_detection_area = 20
+invert_hue = False
+
+[DataCollection]
+# all data collection related parameters
+# set sample_images True/False to enable/disable image collection
+sample_images = False
+# image collection, sample method include: 'bbox' | 'square' | 'whole'
+sample_method = whole
+sample_frequency = 30
+save_directory = /media/owl/SanDisk
+# set to True to disable weed detection for data collection only
+disable_detection = False
+# elog fps
+log_fps = False
+camera_name = cam1
+
+[Relays]
+# defines the relay ID (left) that matches to a boardpin (right) on the Pi.
+# Only change if you rewire/change the relay connections.
+0 = 13
+1 = 15
+2 = 16
+3 = 18
+
+
diff --git a/config/DAY_SENSITIVITY_2.ini b/config/DAY_SENSITIVITY_2.ini
new file mode 100644
index 0000000..0c8b1ba
--- /dev/null
+++ b/config/DAY_SENSITIVITY_2.ini
@@ -0,0 +1,76 @@
+[System]
+# select your algorithm
+algorithm = exhsv
+# operate on a video, image or directory of media
+input_file_or_directory =
+# choose how many relays are connected to the OWL
+relay_num = 4
+actuation_duration = 0.15
+delay = 0
+
+[Controller]
+# choose between 'none', 'ute' or 'advanced' - avoid using '' or "". Just plain text only: none or ute or advanced
+controller_type = none
+
+# for advanced controller
+detection_mode_pin_up = 35
+detection_mode_pin_down = 36
+recording_pin = 38
+sensitivity_pin = 40
+low_sensitivity_config = config/DAY_SENSITIVITY_2.ini
+high_sensitivity_config = config/DAY_SENSITIVITY_3.ini
+
+# for UteController
+switch_purpose = recording
+switch_pin = 37
+
+[Visualisation]
+image_loop_time = 5
+
+[Camera]
+resolution_width = 640
+resolution_height = 480
+exp_compensation = -2
+
+[GreenOnGreen]
+# parameters related to green-on-green detection
+model_path = models
+confidence = 0.5
+class_filter_id = None
+
+[GreenOnBrown]
+# parameters related to green-on-brown detection
+exg_min = 25
+exg_max = 200
+hue_min = 39
+hue_max = 83
+saturation_min = 50
+saturation_max = 220
+brightness_min = 60
+brightness_max = 190
+min_detection_area = 10
+invert_hue = False
+
+[DataCollection]
+# all data collection related parameters
+# set sample_images True/False to enable/disable image collection
+sample_images = False
+# image collection, sample method include: 'bbox' | 'square' | 'whole'
+sample_method = whole
+sample_frequency = 30
+save_directory = /media/owl/SanDisk
+# set to True to disable weed detection for data collection only
+disable_detection = False
+# log fps
+log_fps = False
+camera_name = cam1
+
+[Relays]
+# defines the relay ID (left) that matches to a boardpin (right) on the Pi.
+# Only change if you rewire/change the relay connections.
+0 = 13
+1 = 15
+2 = 16
+3 = 18
+
+
diff --git a/config/DAY_SENSITIVITY_3.ini b/config/DAY_SENSITIVITY_3.ini
new file mode 100644
index 0000000..6450f4d
--- /dev/null
+++ b/config/DAY_SENSITIVITY_3.ini
@@ -0,0 +1,76 @@
+[System]
+# select your algorithm
+algorithm = exhsv
+# operate on a video, image or directory of media
+input_file_or_directory =
+# choose how many relays are connected to the OWL
+relay_num = 4
+actuation_duration = 0.15
+delay = 0
+
+[Controller]
+# choose between 'none', 'ute' or 'advanced' - avoid using '' or "". Just plain text only: none or ute or advanced
+controller_type = none
+
+# for advanced controller
+detection_mode_pin_up = 35
+detection_mode_pin_down = 36
+recording_pin = 38
+sensitivity_pin = 40
+low_sensitivity_config = config/DAY_SENSITIVITY_2.ini
+high_sensitivity_config = config/DAY_SENSITIVITY_3.ini
+
+# for UteController
+switch_purpose = recording
+switch_pin = 37
+
+[Visualisation]
+image_loop_time = 5
+
+[Camera]
+resolution_width = 416
+resolution_height = 320
+exp_compensation = -2
+
+[GreenOnGreen]
+# parameters related to green-on-green detection
+model_path = models
+confidence = 0.5
+class_filter_id = None
+
+[GreenOnBrown]
+# parameters related to green-on-brown detection
+exg_min = 22
+exg_max = 210
+hue_min = 35
+hue_max = 85
+saturation_min = 40
+saturation_max = 225
+brightness_min = 50
+brightness_max = 200
+min_detection_area = 5
+invert_hue = False
+
+[DataCollection]
+# all data collection related parameters
+# set sample_images True/False to enable/disable image collection
+sample_images = False
+# image collection, sample method include: 'bbox' | 'square' | 'whole'
+sample_method = whole
+sample_frequency = 30
+save_directory = /media/owl/SanDisk
+# set to True to disable weed detection for data collection only
+disable_detection = False
+# log fps
+log_fps = False
+camera_name = cam1
+
+[Relays]
+# defines the relay ID (left) that matches to a boardpin (right) on the Pi.
+# Only change if you rewire/change the relay connections.
+0 = 13
+1 = 15
+2 = 16
+3 = 18
+
+
diff --git a/dev/clean.sh b/dev/clean.sh
index 00b79bd..9d23018 100644
--- a/dev/clean.sh
+++ b/dev/clean.sh
@@ -1,14 +1,42 @@
-cd /home/pi
+#!/bin/bash
+cd /home/owl
+
+# Removing user histories and sensitive files
echo "[INFO] Removing history files"
-sudo rm -rvf {/root,/home/pi}/{.bash_history,.viminfo,.lesshst,.ssh/known_hosts}
+sudo rm -rvf /root/.bash_history /home/owl/.bash_history /root/.viminfo /home/owl/.viminfo /root/.lesshst /home/owl/.lesshst
+sudo rm -rvf /root/.ssh /home/owl/.ssh /root/.gnupg /home/owl/.gnupg
+
+# Clearing network information
+echo "[INFO] Clearing network information"
+sudo rm -rvf /etc/NetworkManager/system-connections/*
-echo "[INFO] Emptying /storage"
-sudo rm -rvf /storage/*
+# Emptying user-specific and system-wide temporary data
+echo "[INFO] Emptying temporary storage"
+sudo rm -rvf /tmp/* /var/tmp/*
+# Removing logs
echo "[INFO] Removing logs"
sudo rm -rvf /var/log/*
+# Clear command history for the current session
+history -c
+
+read -p "Zero free space? (y/n): " choice
+case "$choice" in
+ y|Y )
+ echo "[INFO] Zeroing free space"
+ sudo dd if=/dev/zero of=/bigfile bs=1M status=progress
+ sudo rm /bigfile
+ df -h # Display disk usage after zeroing
+ echo "[INFO] Free space zeroed successfully";;
+ n|N )
+ echo "[INFO] Zeroing skipped";;
+ * )
+ echo "[ERROR] Invalid input. Please enter y or n.";;
+esac
+
+# Shutting down the system
echo "[INFO] Shutting down in 5 seconds"
sleep 5
-sudo shutdown
\ No newline at end of file
+sudo shutdown -h now
\ No newline at end of file
diff --git a/images/readme.md b/display/__init__.py
similarity index 100%
rename from images/readme.md
rename to display/__init__.py
diff --git a/docs/20230331_owl_readme.pdf b/docs/20230331_owl_readme.pdf
new file mode 100644
index 0000000..4c2fe96
Binary files /dev/null and b/docs/20230331_owl_readme.pdf differ
diff --git a/docs/20240528_owl_readme.pdf b/docs/20240528_owl_readme.pdf
new file mode 100644
index 0000000..bd00bb4
Binary files /dev/null and b/docs/20240528_owl_readme.pdf differ
diff --git a/environment.yml b/environment.yml
new file mode 100644
index 0000000..0c58280
--- /dev/null
+++ b/environment.yml
@@ -0,0 +1,21 @@
+name: owl
+channels:
+ - conda-forge
+ - defaults
+dependencies:
+ - python=3.12
+ - numpy
+ - pandas
+ - imutils
+ - tqdm
+ - python-dateutil
+ - pytz
+ - six
+ - wcwidth
+ - pip
+ - pip:
+ - blessed==1.20.0
+ - colorzero==2.0
+ - glob2==0.7
+ - gpiozero==1.6.2
+ - opencv-contrib-python
diff --git a/greenonbrown.py b/greenonbrown.py
deleted file mode 100644
index cfd7ba4..0000000
--- a/greenonbrown.py
+++ /dev/null
@@ -1,118 +0,0 @@
-#!/home/pi/.virtualenvs/owl/bin/python3
-from algorithms import exg, exg_standardised, exg_standardised_hue, hsv, exgr, gndvi, maxg
-from imutils import grab_contours
-import numpy as np
-import cv2
-
-
-class GreenOnBrown:
- def __init__(self, algorithm='exg', label_file='models/labels.txt'):
- self.algorithm = algorithm
-
- def inference(self,
- image,
- exgMin=30,
- exgMax=250,
- hueMin=30,
- hueMax=90,
- brightnessMin=5,
- brightnessMax=200,
- saturationMin=30,
- saturationMax=255,
- minArea=1,
- show_display=False,
- algorithm='exg'):
- '''
- Uses a provided algorithm and contour detection to determine green objects in the image. Min and Max
- thresholds are provided.
- :param image: input image to be analysed
- :param exgMin: minimum exG threshold value
- :param exgMax: maximum exG threshold value
- :param hueMin: minimum hue threshold value
- :param hueMax: maximum hue threshold value
- :param brightnessMin: minimum brightness threshold value
- :param brightnessMax: maximum brightness threshold value
- :param saturationMin: minimum saturation threshold value
- :param saturationMax: maximum saturation threshold value
- :param minArea: minimum area for the detection - used to filter out small detections
- :param show_display: True: show windows; False: operates in headless mode
- :param algorithm: the algorithm to use. Defaults to ExG if not correct
- :return: returns the contours, bounding boxes, centroids and the image on which the boxes have been drawn
- '''
-
- # different algorithm options, add in your algorithm here if you make a new one!
- threshedAlready = False
- if algorithm == 'exg':
- output = exg(image)
-
- elif algorithm == 'exgr':
- output = exgr(image)
-
- elif algorithm == 'maxg':
- output = maxg(image)
-
- elif algorithm == 'nexg':
- output = exg_standardised(image)
-
- elif algorithm == 'exhsv':
- output = exg_standardised_hue(image, hueMin=hueMin, hueMax=hueMax,
- brightnessMin=brightnessMin, brightnessMax=brightnessMax,
- saturationMin=saturationMin, saturationMax=saturationMax)
-
- elif algorithm == 'hsv':
- output, threshedAlready = hsv(image, hueMin=hueMin, hueMax=hueMax,
- brightnessMin=brightnessMin, brightnessMax=brightnessMax,
- saturationMin=saturationMin, saturationMax=saturationMax)
-
- elif algorithm == 'gndvi':
- output = gndvi(image)
-
- else:
- output = exg(image)
- print('[WARNING] DEFAULTED TO EXG')
-
- # run the thresholds provided
- kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
- self.weedCenters = []
- self.boxes = []
-
- # if not a binary image, run an adaptive threshold on the area that fits within the thresholded bounds.
- if not threshedAlready:
- output = np.where(output > exgMin, output, 0)
- output = np.where(output > exgMax, 0, output)
- output = np.uint8(np.abs(output))
- if show_display:
- cv2.imshow("HSV Threshold on ExG", output)
-
- thresholdOut = cv2.adaptiveThreshold(output, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 31, 2)
- thresholdOut = cv2.morphologyEx(thresholdOut, cv2.MORPH_CLOSE, kernel, iterations=1)
-
- # if already binary, run morphological operations to remove any noise
- if threshedAlready:
- thresholdOut = cv2.morphologyEx(output, cv2.MORPH_CLOSE, kernel, iterations=5)
-
- if show_display:
- cv2.imshow("Binary Threshold", thresholdOut)
-
- # find all the contours on the binary images
- self.cnts = cv2.findContours(thresholdOut.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
- self.cnts = grab_contours(self.cnts)
-
- # loop over all the detected contours and calculate the centres and bounding boxes
- for c in self.cnts:
- # filter based on total area of contour
- if cv2.contourArea(c) > minArea:
- # calculate the min bounding box
- startX, startY, boxW, boxH = cv2.boundingRect(c)
- endX = startX + boxW
- endY = startY + boxH
- cv2.rectangle(image, (int(startX), int(startY)), (endX, endY), (0, 0, 255), 2)
- # save the bounding box
- self.boxes.append([startX, startY, boxW, boxH])
- # compute box center
- centerX = int(startX + (boxW / 2))
- centerY = int(startY + (boxH / 2))
- self.weedCenters.append([centerX, centerY])
-
- # returns the contours, bounding boxes, centroids and the image on which the boxes have been drawn
- return self.cnts, self.boxes, self.weedCenters, image
diff --git a/image_sampler.py b/image_sampler.py
deleted file mode 100644
index 177d5df..0000000
--- a/image_sampler.py
+++ /dev/null
@@ -1,63 +0,0 @@
-from time import strftime
-import numpy as np
-import cv2
-import os
-
-
-def whole_image_save(image, save_directory, frame_id):
- fname = "{}_frame_{}.png".format(strftime("%Y%m%d-%H%M%S_"), frame_id)
- cv2.imwrite(os.path.join(save_directory, fname), image)
-
-
-def bounding_box_image_sample(image, bounding_boxes, save_directory, frame_id):
- '''
- Generates and saves a cropped section of whole image based on bbox coordinates
- :param image: input image array
- :param bounding_boxes: bounding box coordinates in list of form [[startX, startY, boxW, boxH], [...]]
- :param saveDir: save directory
- '''
- for contour_id, box in enumerate(bounding_boxes):
- startX = box[0]
- startY = box[1]
- endX = startX + box[2]
- endY = startY + box[3]
-
- cropped_image = image[startY:endY, startX:endX]
- fname = "{}_frame_{}_n_{}.png".format(strftime("%Y%m%d-%H%M%S_"), frame_id, str(contour_id))
- cv2.imwrite(os.path.join(save_directory, fname), cropped_image)
-
-
-def square_image_sample(image, centres_list, save_directory, frame_id, side_length=200):
- """
- Generates and saves random square image crop around a target centre
- :param image: input image to collect snapshot from
- :param centresList: list of target centres
- :param sideLength: dimensions of square
- """
- if side_length > image.shape[0]:
- side_length = image.shape[0]
- halfLength = int(side_length / 2)
-
- # compute startX and StartY of the cropped area
- for contour_id, centre in enumerate(centres_list):
- startX = centre[0] - np.random.randint(10, halfLength)
- if startX < 0:
- startX = 0
- startY = centre[1] - np.random.randint(10, halfLength)
- if startY < 0:
- startY = 0
- endX = startX + side_length
- endY = startY + side_length
-
- # check if box fits on image, if not compute from max edge
- if endX > image.shape[1]:
- endX = image.shape[1]
- startX = image.shape[1] - side_length
- if endY > image.shape[0]:
- endY = image.shape[0]
- startY = image.shape[0] - side_length
-
- # use numpy array slicing to crop image and save
- square_image = image[startY:endY, startX:endX]
- fname = "{}_frame_{}_n_{}.png".format(strftime("%Y%m%d-%H%M%S_"), frame_id, str(contour_id))
- cv2.imwrite(os.path.join(save_directory, fname), square_image)
diff --git a/images/Desktop.jpg b/images/Desktop.jpg
deleted file mode 100644
index 4f248c9..0000000
Binary files a/images/Desktop.jpg and /dev/null differ
diff --git a/images/Enclosure3D.JPG b/images/Enclosure3D.JPG
deleted file mode 100644
index 202162b..0000000
Binary files a/images/Enclosure3D.JPG and /dev/null differ
diff --git a/images/OpenSpotSprayer_internal.jpg b/images/OpenSpotSprayer_internal.jpg
deleted file mode 100644
index 22332d1..0000000
Binary files a/images/OpenSpotSprayer_internal.jpg and /dev/null differ
diff --git a/images/OpenSpotSprayer_module.jpg b/images/OpenSpotSprayer_module.jpg
deleted file mode 100644
index bebdc7f..0000000
Binary files a/images/OpenSpotSprayer_module.jpg and /dev/null differ
diff --git a/images/owl-background.png b/images/owl-background.png
new file mode 100644
index 0000000..af2b83a
Binary files /dev/null and b/images/owl-background.png differ
diff --git a/logger.py b/logger.py
deleted file mode 100644
index 4a73209..0000000
--- a/logger.py
+++ /dev/null
@@ -1,36 +0,0 @@
-from time import strftime
-from datetime import datetime, timezone
-import os
-
-# this class logs everything that happens - detections, coordinates (if supplied) and nozzle
-# will also log errors and framerates
-class Logger:
- def __init__(self, name, saveDir):
- self.name = strftime("%Y%m%d-%H%M%S_") + name
- self.saveDir = saveDir
- if not os.path.exists(self.saveDir):
- os.makedirs(self.saveDir)
-
- self.savePath = os.path.join(self.saveDir, self.name)
- self.logList = []
-
- def log_line(self, line, verbose=False):
- self.line = str(datetime.now(timezone.utc)) + " " + line + "\n"
- if verbose:
- print(line)
- with open(self.savePath, 'a+') as file:
- file.write(self.line)
- self.logList.append(self.line)
-
- def log_line_video(self, line, verbose):
- self.log_line(line, verbose=False)
- self.videoLine = str(datetime.now(timezone.utc)) + " " + line + "\n"
- if verbose:
- print(line)
-
- with open(self.videoLog, 'a+') as file:
- file.write(self.videoLine)
-
- def new_video_logfile(self, name):
- self.videoLog = name
- self.log_line_video('NEW VIDEO LOG CREATED {}'.format(name), verbose=True)
diff --git a/logs/20201001-181407_weed_log.txt b/logs/20201001-181407_weed_log.txt
deleted file mode 100644
index 19843ce..0000000
--- a/logs/20201001-181407_weed_log.txt
+++ /dev/null
@@ -1,32 +0,0 @@
-2020-10-01 08:14:10.514081+00:00 nozzle: 1 | time: 1601540050.5140815 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:10.573081+00:00 nozzle: 1 | time: 1601540050.573082 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:10.606081+00:00 nozzle: 1 | time: 1601540050.6060812 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:10.653083+00:00 nozzle: 1 | time: 1601540050.6530833 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:10.701081+00:00 nozzle: 1 | time: 1601540050.701081 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:10.766082+00:00 nozzle: 1 | time: 1601540050.7660823 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:10.804600+00:00 nozzle: 1 | time: 1601540050.8046002 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:10.835601+00:00 nozzle: 1 | time: 1601540050.8356018 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:10.903602+00:00 nozzle: 1 | time: 1601540050.9036024 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:10.904614+00:00 nozzle: 1 | time: 1601540050.9046142 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:10.946601+00:00 nozzle: 1 | time: 1601540050.9466019 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:10.977603+00:00 nozzle: 1 | time: 1601540050.977603 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:11.021603+00:00 nozzle: 1 | time: 1601540051.021603 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:11.056601+00:00 nozzle: 1 | time: 1601540051.0566018 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:11.088601+00:00 nozzle: 1 | time: 1601540051.0886016 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:11.121606+00:00 nozzle: 1 | time: 1601540051.1216063 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:11.169601+00:00 nozzle: 1 | time: 1601540051.1696017 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:11.199600+00:00 nozzle: 1 | time: 1601540051.199601 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:11.233601+00:00 nozzle: 1 | time: 1601540051.2336018 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:11.234603+00:00 nozzle: 1 | time: 1601540051.2346032 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:11.235601+00:00 nozzle: 1 | time: 1601540051.2356017 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:11.305601+00:00 nozzle: 1 | time: 1601540051.3056018 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:11.907601+00:00 nozzle: 1 | time: 1601540051.9076018 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:11.964601+00:00 nozzle: 1 | time: 1601540051.964601 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:12.000601+00:00 nozzle: 1 | time: 1601540052.0006013 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:12.001600+00:00 nozzle: 1 | time: 1601540052.0016 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:12.032601+00:00 nozzle: 1 | time: 1601540052.0326014 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:12.064603+00:00 nozzle: 1 | time: 1601540052.0646038 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:12.065605+00:00 nozzle: 1 | time: 1601540052.0656054 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:12.114600+00:00 nozzle: 1 | time: 1601540052.1146 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:12.153601+00:00 nozzle: 1 | time: 1601540052.1536012 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:12.454602+00:00 nozzle: 1 | time: 1601540052.4546027 | location 0 | delay: 0 | duration: 1
diff --git a/logs/20201001-181430_weed_log.txt b/logs/20201001-181430_weed_log.txt
deleted file mode 100644
index 02c73d7..0000000
--- a/logs/20201001-181430_weed_log.txt
+++ /dev/null
@@ -1,167 +0,0 @@
-2020-10-01 08:14:33.556261+00:00 nozzle: 1 | time: 1601540073.5562618 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:33.613265+00:00 nozzle: 1 | time: 1601540073.613265 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:33.649261+00:00 nozzle: 1 | time: 1601540073.6492615 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:33.680261+00:00 nozzle: 1 | time: 1601540073.6802614 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:33.714266+00:00 nozzle: 1 | time: 1601540073.713261 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:33.789260+00:00 nozzle: 1 | time: 1601540073.7892609 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:33.837261+00:00 nozzle: 1 | time: 1601540073.837261 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:33.868262+00:00 nozzle: 1 | time: 1601540073.868262 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:33.901261+00:00 nozzle: 1 | time: 1601540073.9012613 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:33.902260+00:00 nozzle: 1 | time: 1601540073.902261 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:33.948261+00:00 nozzle: 1 | time: 1601540073.9482613 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:33.981261+00:00 nozzle: 1 | time: 1601540073.981261 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:34.036262+00:00 nozzle: 1 | time: 1601540074.036262 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:34.067260+00:00 nozzle: 1 | time: 1601540074.0672605 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:34.103260+00:00 nozzle: 1 | time: 1601540074.1032605 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:34.134261+00:00 nozzle: 1 | time: 1601540074.134262 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:34.181265+00:00 nozzle: 1 | time: 1601540074.1812654 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:34.212261+00:00 nozzle: 1 | time: 1601540074.2122612 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:34.246264+00:00 nozzle: 1 | time: 1601540074.2462645 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:34.247261+00:00 nozzle: 1 | time: 1601540074.2472615 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:34.248269+00:00 nozzle: 1 | time: 1601540074.248269 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:34.321260+00:00 nozzle: 1 | time: 1601540074.3212602 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:34.906260+00:00 nozzle: 1 | time: 1601540074.9052603 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:34.944263+00:00 nozzle: 1 | time: 1601540074.9442632 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:34.970272+00:00 nozzle: 1 | time: 1601540074.9702723 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:34.972260+00:00 nozzle: 1 | time: 1601540074.9722607 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:35.015261+00:00 nozzle: 1 | time: 1601540075.0152614 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:35.047260+00:00 nozzle: 1 | time: 1601540075.0472608 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:35.048260+00:00 nozzle: 1 | time: 1601540075.0482607 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:35.084261+00:00 nozzle: 1 | time: 1601540075.0842617 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:35.113263+00:00 nozzle: 1 | time: 1601540075.1132631 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:35.396261+00:00 nozzle: 1 | time: 1601540075.3962612 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:36.891265+00:00 nozzle: 1 | time: 1601540076.8912659 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:36.921264+00:00 nozzle: 1 | time: 1601540076.921265 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:36.967262+00:00 nozzle: 1 | time: 1601540076.9672623 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:37.001261+00:00 nozzle: 1 | time: 1601540077.0012617 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:37.030261+00:00 nozzle: 1 | time: 1601540077.0302618 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:37.065310+00:00 nozzle: 1 | time: 1601540077.0653107 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:37.472264+00:00 nozzle: 1 | time: 1601540077.4722645 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:39.375265+00:00 nozzle: 2 | time: 1601540079.3752651 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:39.376264+00:00 nozzle: 1 | time: 1601540079.3762648 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:39.421263+00:00 nozzle: 2 | time: 1601540079.4212635 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:39.422262+00:00 nozzle: 1 | time: 1601540079.4222622 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:39.468262+00:00 nozzle: 2 | time: 1601540079.468263 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:39.469268+00:00 nozzle: 1 | time: 1601540079.4692686 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:39.469268+00:00 nozzle: 0 | time: 1601540079.4692686 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:39.530263+00:00 nozzle: 2 | time: 1601540079.5302637 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:39.531261+00:00 nozzle: 1 | time: 1601540079.5312617 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:39.532263+00:00 nozzle: 0 | time: 1601540079.5322633 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:39.578263+00:00 nozzle: 2 | time: 1601540079.5782635 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:39.579263+00:00 nozzle: 1 | time: 1601540079.5792632 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:39.580262+00:00 nozzle: 0 | time: 1601540079.5792632 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:39.631265+00:00 nozzle: 2 | time: 1601540079.6302783 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:39.632264+00:00 nozzle: 1 | time: 1601540079.6322641 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:39.633266+00:00 nozzle: 0 | time: 1601540079.6332664 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:39.669263+00:00 nozzle: 2 | time: 1601540079.6692636 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:39.670262+00:00 nozzle: 1 | time: 1601540079.6702626 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:39.670262+00:00 nozzle: 0 | time: 1601540079.6702626 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:39.698264+00:00 nozzle: 2 | time: 1601540079.6982641 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:39.699268+00:00 nozzle: 0 | time: 1601540079.6992686 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:39.729264+00:00 nozzle: 2 | time: 1601540079.7292645 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:39.730263+00:00 nozzle: 0 | time: 1601540079.730263 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.023264+00:00 nozzle: 2 | time: 1601540081.023264 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.054263+00:00 nozzle: 2 | time: 1601540081.0542636 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.085263+00:00 nozzle: 2 | time: 1601540081.085264 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.086266+00:00 nozzle: 2 | time: 1601540081.0862665 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.145264+00:00 nozzle: 2 | time: 1601540081.1452649 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.146266+00:00 nozzle: 2 | time: 1601540081.1462662 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.147266+00:00 nozzle: 0 | time: 1601540081.1472669 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.180264+00:00 nozzle: 2 | time: 1601540081.1802645 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.181266+00:00 nozzle: 2 | time: 1601540081.181266 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.181266+00:00 nozzle: 2 | time: 1601540081.181266 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.182266+00:00 nozzle: 2 | time: 1601540081.182266 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.182266+00:00 nozzle: 0 | time: 1601540081.182266 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.212264+00:00 nozzle: 2 | time: 1601540081.2122638 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.212264+00:00 nozzle: 2 | time: 1601540081.2122638 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.213267+00:00 nozzle: 0 | time: 1601540081.2132668 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.214270+00:00 nozzle: 0 | time: 1601540081.21427 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.253264+00:00 nozzle: 2 | time: 1601540081.253265 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.254265+00:00 nozzle: 0 | time: 1601540081.2542653 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.255287+00:00 nozzle: 0 | time: 1601540081.255288 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.293264+00:00 nozzle: 2 | time: 1601540081.2932646 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.294263+00:00 nozzle: 0 | time: 1601540081.2942638 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.295263+00:00 nozzle: 0 | time: 1601540081.2952638 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.327266+00:00 nozzle: 2 | time: 1601540081.3272665 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.327266+00:00 nozzle: 2 | time: 1601540081.3272665 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.328264+00:00 nozzle: 0 | time: 1601540081.3282647 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.328264+00:00 nozzle: 0 | time: 1601540081.3282647 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.371264+00:00 nozzle: 0 | time: 1601540081.371264 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.372273+00:00 nozzle: 0 | time: 1601540081.371264 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.431265+00:00 nozzle: 0 | time: 1601540081.4312654 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.465264+00:00 nozzle: 0 | time: 1601540081.4652643 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.495265+00:00 nozzle: 0 | time: 1601540081.4952652 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.530266+00:00 nozzle: 0 | time: 1601540081.5302663 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.581264+00:00 nozzle: 0 | time: 1601540081.5812647 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:41.629266+00:00 nozzle: 0 | time: 1601540081.628264 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:44.454265+00:00 nozzle: 3 | time: 1601540084.4542658 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:44.487266+00:00 nozzle: 3 | time: 1601540084.4872668 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:44.541267+00:00 nozzle: 3 | time: 1601540084.5412679 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:44.585268+00:00 nozzle: 3 | time: 1601540084.5852683 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:44.628281+00:00 nozzle: 3 | time: 1601540084.6282818 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:46.678268+00:00 nozzle: 0 | time: 1601540086.6782682 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:46.716267+00:00 nozzle: 0 | time: 1601540086.7162673 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:46.791270+00:00 nozzle: 0 | time: 1601540086.7912703 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:46.828267+00:00 nozzle: 0 | time: 1601540086.828267 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:46.879267+00:00 nozzle: 0 | time: 1601540086.8792677 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:46.923269+00:00 nozzle: 0 | time: 1601540086.923269 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:46.965267+00:00 nozzle: 0 | time: 1601540086.9652672 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:47.004270+00:00 nozzle: 0 | time: 1601540087.0032678 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:47.045267+00:00 nozzle: 0 | time: 1601540087.045267 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:47.142265+00:00 nozzle: 0 | time: 1601540087.1422658 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:47.185266+00:00 nozzle: 0 | time: 1601540087.1852665 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:47.232267+00:00 nozzle: 0 | time: 1601540087.2322676 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:47.282266+00:00 nozzle: 0 | time: 1601540087.2812707 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:47.326274+00:00 nozzle: 0 | time: 1601540087.3262749 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:47.372266+00:00 nozzle: 0 | time: 1601540087.3722665 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:48.423269+00:00 nozzle: 1 | time: 1601540088.423269 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:48.470267+00:00 nozzle: 1 | time: 1601540088.4702675 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:48.471266+00:00 nozzle: 1 | time: 1601540088.4712667 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:48.473289+00:00 nozzle: 1 | time: 1601540088.4732893 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:48.516267+00:00 nozzle: 1 | time: 1601540088.5162678 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:48.517267+00:00 nozzle: 1 | time: 1601540088.5172672 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:48.518267+00:00 nozzle: 1 | time: 1601540088.5182674 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:48.519267+00:00 nozzle: 1 | time: 1601540088.519267 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:48.564266+00:00 nozzle: 1 | time: 1601540088.564267 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:48.565268+00:00 nozzle: 1 | time: 1601540088.5652685 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:48.566267+00:00 nozzle: 1 | time: 1601540088.5652685 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:48.609268+00:00 nozzle: 1 | time: 1601540088.6092682 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:48.610276+00:00 nozzle: 1 | time: 1601540088.6102765 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:48.611268+00:00 nozzle: 1 | time: 1601540088.611268 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:48.661279+00:00 nozzle: 1 | time: 1601540088.6612797 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:48.663269+00:00 nozzle: 1 | time: 1601540088.6622772 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:48.663269+00:00 nozzle: 1 | time: 1601540088.663269 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:48.712268+00:00 nozzle: 1 | time: 1601540088.7122688 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:48.713269+00:00 nozzle: 1 | time: 1601540088.7132697 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:48.752269+00:00 nozzle: 1 | time: 1601540088.7522693 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:48.831271+00:00 nozzle: 3 | time: 1601540088.8312712 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:48.875269+00:00 nozzle: 3 | time: 1601540088.8752697 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:48.876270+00:00 nozzle: 3 | time: 1601540088.87627 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:48.911271+00:00 nozzle: 3 | time: 1601540088.911272 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:48.912267+00:00 nozzle: 3 | time: 1601540088.912267 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:48.958268+00:00 nozzle: 3 | time: 1601540088.9582686 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:48.959272+00:00 nozzle: 3 | time: 1601540088.959272 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:49.003270+00:00 nozzle: 3 | time: 1601540089.0032706 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:49.054274+00:00 nozzle: 3 | time: 1601540089.0542748 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:49.100267+00:00 nozzle: 3 | time: 1601540089.1002674 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:49.144271+00:00 nozzle: 3 | time: 1601540089.1442711 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:49.189272+00:00 nozzle: 3 | time: 1601540089.1892729 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:49.235269+00:00 nozzle: 3 | time: 1601540089.235269 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:49.280270+00:00 nozzle: 3 | time: 1601540089.28027 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:49.550272+00:00 nozzle: 2 | time: 1601540089.550272 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:49.584268+00:00 nozzle: 2 | time: 1601540089.5842688 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:49.628269+00:00 nozzle: 2 | time: 1601540089.6282697 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:49.678268+00:00 nozzle: 2 | time: 1601540089.678269 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:49.679285+00:00 nozzle: 2 | time: 1601540089.6792858 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:49.725268+00:00 nozzle: 2 | time: 1601540089.7252681 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:49.726269+00:00 nozzle: 2 | time: 1601540089.7262692 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:49.768267+00:00 nozzle: 2 | time: 1601540089.7682679 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:49.803269+00:00 nozzle: 2 | time: 1601540089.8032691 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:49.849277+00:00 nozzle: 2 | time: 1601540089.8492777 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:49.896270+00:00 nozzle: 2 | time: 1601540089.89627 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:49.897270+00:00 nozzle: 2 | time: 1601540089.8972702 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:51.675278+00:00 nozzle: 0 | time: 1601540091.6752787 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:51.720269+00:00 nozzle: 0 | time: 1601540091.7192724 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:51.769275+00:00 nozzle: 0 | time: 1601540091.769275 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:51.816273+00:00 nozzle: 0 | time: 1601540091.8162732 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:14:51.863273+00:00 nozzle: 0 | time: 1601540091.8632734 | location 0 | delay: 0 | duration: 1
diff --git a/logs/20201001-181815_weed_log.txt b/logs/20201001-181815_weed_log.txt
deleted file mode 100644
index 7ed90cf..0000000
--- a/logs/20201001-181815_weed_log.txt
+++ /dev/null
@@ -1,96 +0,0 @@
-2020-10-01 08:18:18.362053+00:00 nozzle: 1 | time: 1601540298.3620534 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:18.409053+00:00 nozzle: 1 | time: 1601540298.409054 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:18.474055+00:00 nozzle: 1 | time: 1601540298.474056 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:18.518067+00:00 nozzle: 1 | time: 1601540298.5180676 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:18.551049+00:00 nozzle: 1 | time: 1601540298.5500507 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:18.611050+00:00 nozzle: 1 | time: 1601540298.6110506 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:18.659050+00:00 nozzle: 1 | time: 1601540298.659051 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:18.690052+00:00 nozzle: 1 | time: 1601540298.6900527 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:18.754051+00:00 nozzle: 1 | time: 1601540298.7540517 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:18.757053+00:00 nozzle: 1 | time: 1601540298.7570534 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:18.800055+00:00 nozzle: 1 | time: 1601540298.8000553 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:18.831065+00:00 nozzle: 1 | time: 1601540298.8310654 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:18.883051+00:00 nozzle: 1 | time: 1601540298.8830516 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:18.926052+00:00 nozzle: 1 | time: 1601540298.9260528 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:18.956051+00:00 nozzle: 1 | time: 1601540298.9560509 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:19.009051+00:00 nozzle: 1 | time: 1601540299.009051 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:19.045050+00:00 nozzle: 1 | time: 1601540299.0450509 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:19.079052+00:00 nozzle: 1 | time: 1601540299.0790522 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:19.112051+00:00 nozzle: 1 | time: 1601540299.1120517 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:19.114082+00:00 nozzle: 1 | time: 1601540299.1140828 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:19.115073+00:00 nozzle: 1 | time: 1601540299.1150732 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:19.166050+00:00 nozzle: 1 | time: 1601540299.1660507 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:20.367140+00:00 nozzle: 1 | time: 1601540300.3671403 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:20.402144+00:00 nozzle: 1 | time: 1601540300.4011447 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:20.451140+00:00 nozzle: 1 | time: 1601540300.4511404 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:20.452139+00:00 nozzle: 1 | time: 1601540300.4511404 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:20.495139+00:00 nozzle: 1 | time: 1601540300.4951398 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:20.523140+00:00 nozzle: 1 | time: 1601540300.5231407 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:20.524139+00:00 nozzle: 1 | time: 1601540300.5241394 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:20.574142+00:00 nozzle: 1 | time: 1601540300.5741422 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:20.602143+00:00 nozzle: 1 | time: 1601540300.6021433 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:20.867140+00:00 nozzle: 1 | time: 1601540300.8671403 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:22.339146+00:00 nozzle: 1 | time: 1601540302.3391461 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:22.374146+00:00 nozzle: 1 | time: 1601540302.3741462 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:22.420142+00:00 nozzle: 1 | time: 1601540302.4201427 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:22.455141+00:00 nozzle: 1 | time: 1601540302.4551415 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:22.484141+00:00 nozzle: 1 | time: 1601540302.4841416 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:22.517142+00:00 nozzle: 1 | time: 1601540302.5171428 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:22.947141+00:00 nozzle: 1 | time: 1601540302.9471414 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:24.864143+00:00 nozzle: 2 | time: 1601540304.8641431 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:24.865145+00:00 nozzle: 1 | time: 1601540304.8651454 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:24.895144+00:00 nozzle: 2 | time: 1601540304.895144 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:24.897158+00:00 nozzle: 1 | time: 1601540304.8971581 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:24.928142+00:00 nozzle: 2 | time: 1601540304.9281425 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:24.929142+00:00 nozzle: 1 | time: 1601540304.929142 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:24.930162+00:00 nozzle: 0 | time: 1601540304.9301627 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:24.964147+00:00 nozzle: 2 | time: 1601540304.964147 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:24.965143+00:00 nozzle: 1 | time: 1601540304.964147 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:24.965143+00:00 nozzle: 0 | time: 1601540304.9651432 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:25.019142+00:00 nozzle: 2 | time: 1601540305.018144 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:25.019142+00:00 nozzle: 1 | time: 1601540305.0191426 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:25.020144+00:00 nozzle: 0 | time: 1601540305.020145 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:25.055141+00:00 nozzle: 2 | time: 1601540305.0551417 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:25.055141+00:00 nozzle: 1 | time: 1601540305.0551417 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:25.056142+00:00 nozzle: 0 | time: 1601540305.056142 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:25.091142+00:00 nozzle: 2 | time: 1601540305.0911424 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:25.092143+00:00 nozzle: 1 | time: 1601540305.092143 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:25.093144+00:00 nozzle: 0 | time: 1601540305.092143 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:25.137143+00:00 nozzle: 2 | time: 1601540305.1371434 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:25.138144+00:00 nozzle: 0 | time: 1601540305.1381443 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:25.181150+00:00 nozzle: 2 | time: 1601540305.181151 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:25.181150+00:00 nozzle: 0 | time: 1601540305.181151 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.406143+00:00 nozzle: 2 | time: 1601540306.4061437 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.434142+00:00 nozzle: 2 | time: 1601540306.4341428 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.473143+00:00 nozzle: 2 | time: 1601540306.4731436 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.474151+00:00 nozzle: 2 | time: 1601540306.4741511 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.513159+00:00 nozzle: 2 | time: 1601540306.5131595 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.514150+00:00 nozzle: 2 | time: 1601540306.5141504 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.515143+00:00 nozzle: 0 | time: 1601540306.5151439 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.549143+00:00 nozzle: 2 | time: 1601540306.5491436 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.550141+00:00 nozzle: 2 | time: 1601540306.550141 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.551142+00:00 nozzle: 2 | time: 1601540306.5511425 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.551142+00:00 nozzle: 2 | time: 1601540306.5511425 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.552142+00:00 nozzle: 0 | time: 1601540306.5521424 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.577147+00:00 nozzle: 2 | time: 1601540306.5771468 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.578142+00:00 nozzle: 2 | time: 1601540306.5781424 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.579144+00:00 nozzle: 0 | time: 1601540306.5791447 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.580157+00:00 nozzle: 0 | time: 1601540306.580158 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.641142+00:00 nozzle: 2 | time: 1601540306.6411426 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.642149+00:00 nozzle: 0 | time: 1601540306.64215 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.643142+00:00 nozzle: 0 | time: 1601540306.64215 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.677143+00:00 nozzle: 2 | time: 1601540306.6771436 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.678145+00:00 nozzle: 0 | time: 1601540306.6781452 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.680678+00:00 nozzle: 0 | time: 1601540306.6801825 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.727143+00:00 nozzle: 2 | time: 1601540306.727143 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.728151+00:00 nozzle: 2 | time: 1601540306.728151 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.729146+00:00 nozzle: 0 | time: 1601540306.729146 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.730143+00:00 nozzle: 0 | time: 1601540306.7301435 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.773144+00:00 nozzle: 0 | time: 1601540306.773144 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.773144+00:00 nozzle: 0 | time: 1601540306.773144 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.799143+00:00 nozzle: 0 | time: 1601540306.7991438 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.833143+00:00 nozzle: 0 | time: 1601540306.8331432 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.871165+00:00 nozzle: 0 | time: 1601540306.8711653 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.915141+00:00 nozzle: 0 | time: 1601540306.9141488 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.944148+00:00 nozzle: 0 | time: 1601540306.9441483 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:18:26.976143+00:00 nozzle: 0 | time: 1601540306.9761434 | location 0 | delay: 0 | duration: 1
diff --git a/logs/20201001-182021_weed_log.txt b/logs/20201001-182021_weed_log.txt
deleted file mode 100644
index 6fc8364..0000000
--- a/logs/20201001-182021_weed_log.txt
+++ /dev/null
@@ -1,101 +0,0 @@
-2020-10-01 08:20:24.211780+00:00 nozzle: 1 | time: 1601540424.21178 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:24.267782+00:00 nozzle: 1 | time: 1601540424.2667806 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:24.322781+00:00 nozzle: 1 | time: 1601540424.3227813 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:24.382793+00:00 nozzle: 1 | time: 1601540424.3827932 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:24.415782+00:00 nozzle: 1 | time: 1601540424.4157825 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:24.453781+00:00 nozzle: 1 | time: 1601540424.4537814 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:24.536333+00:00 nozzle: 1 | time: 1601540424.5363338 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:24.581347+00:00 nozzle: 1 | time: 1601540424.5813472 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:24.642333+00:00 nozzle: 1 | time: 1601540424.6423335 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:24.644332+00:00 nozzle: 1 | time: 1601540424.643333 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:24.703334+00:00 nozzle: 1 | time: 1601540424.7033348 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:24.735863+00:00 nozzle: 1 | time: 1601540424.735864 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:24.790866+00:00 nozzle: 1 | time: 1601540424.7908666 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:24.829859+00:00 nozzle: 1 | time: 1601540424.8298593 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:24.874860+00:00 nozzle: 1 | time: 1601540424.87486 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:24.936862+00:00 nozzle: 1 | time: 1601540424.9358652 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:24.992864+00:00 nozzle: 1 | time: 1601540424.9928646 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:25.058865+00:00 nozzle: 1 | time: 1601540425.058865 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:25.093864+00:00 nozzle: 1 | time: 1601540425.093865 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:25.094859+00:00 nozzle: 1 | time: 1601540425.0948591 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:25.095860+00:00 nozzle: 1 | time: 1601540425.0958607 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:25.152862+00:00 nozzle: 1 | time: 1601540425.1528623 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:25.751859+00:00 nozzle: 1 | time: 1601540425.75186 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:25.778859+00:00 nozzle: 1 | time: 1601540425.7788594 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:25.813864+00:00 nozzle: 1 | time: 1601540425.813865 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:25.816861+00:00 nozzle: 1 | time: 1601540425.8158717 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:25.860858+00:00 nozzle: 1 | time: 1601540425.8608587 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:25.890858+00:00 nozzle: 1 | time: 1601540425.890858 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:25.891857+00:00 nozzle: 1 | time: 1601540425.8918579 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:25.921858+00:00 nozzle: 1 | time: 1601540425.9218585 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:25.976860+00:00 nozzle: 1 | time: 1601540425.9758623 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:26.267860+00:00 nozzle: 1 | time: 1601540426.2678602 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:27.683859+00:00 nozzle: 1 | time: 1601540427.6838598 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:27.717858+00:00 nozzle: 1 | time: 1601540427.7178586 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:27.772859+00:00 nozzle: 1 | time: 1601540427.7728593 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:27.809858+00:00 nozzle: 1 | time: 1601540427.8098583 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:27.841858+00:00 nozzle: 1 | time: 1601540427.8418589 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:27.876859+00:00 nozzle: 1 | time: 1601540427.87686 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:28.319430+00:00 nozzle: 1 | time: 1601540428.3194308 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:30.167431+00:00 nozzle: 2 | time: 1601540430.167431 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:30.168429+00:00 nozzle: 1 | time: 1601540430.1684299 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:30.196434+00:00 nozzle: 2 | time: 1601540430.1964347 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:30.198431+00:00 nozzle: 1 | time: 1601540430.1984317 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:30.230442+00:00 nozzle: 2 | time: 1601540430.229444 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:30.231431+00:00 nozzle: 1 | time: 1601540430.2314315 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:30.231431+00:00 nozzle: 0 | time: 1601540430.2314315 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:30.256431+00:00 nozzle: 2 | time: 1601540430.2564313 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:30.257432+00:00 nozzle: 1 | time: 1601540430.257433 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:30.257432+00:00 nozzle: 0 | time: 1601540430.257433 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:30.313432+00:00 nozzle: 2 | time: 1601540430.3134322 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:30.314431+00:00 nozzle: 1 | time: 1601540430.3144317 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:30.314431+00:00 nozzle: 0 | time: 1601540430.3144317 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:30.357432+00:00 nozzle: 2 | time: 1601540430.3574328 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:30.358431+00:00 nozzle: 1 | time: 1601540430.3584316 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:30.359431+00:00 nozzle: 0 | time: 1601540430.3594315 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:30.407430+00:00 nozzle: 2 | time: 1601540430.406432 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:30.407430+00:00 nozzle: 1 | time: 1601540430.407431 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:30.408433+00:00 nozzle: 0 | time: 1601540430.408433 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:30.450430+00:00 nozzle: 2 | time: 1601540430.4504309 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:30.451431+00:00 nozzle: 0 | time: 1601540430.4514313 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:30.484431+00:00 nozzle: 2 | time: 1601540430.4844315 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:30.485430+00:00 nozzle: 0 | time: 1601540430.4854302 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:31.665463+00:00 nozzle: 2 | time: 1601540431.665463 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:31.711430+00:00 nozzle: 2 | time: 1601540431.7114305 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:31.742431+00:00 nozzle: 2 | time: 1601540431.742432 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:31.743433+00:00 nozzle: 2 | time: 1601540431.7434335 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:31.771448+00:00 nozzle: 2 | time: 1601540431.7714481 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:31.772432+00:00 nozzle: 2 | time: 1601540431.7724326 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:31.773432+00:00 nozzle: 0 | time: 1601540431.7734323 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:31.817432+00:00 nozzle: 2 | time: 1601540431.8174324 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:31.818450+00:00 nozzle: 2 | time: 1601540431.8184505 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:31.819434+00:00 nozzle: 2 | time: 1601540431.8194344 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:31.820433+00:00 nozzle: 2 | time: 1601540431.8204336 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:31.821432+00:00 nozzle: 0 | time: 1601540431.8214326 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:31.865434+00:00 nozzle: 2 | time: 1601540431.865435 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:31.866432+00:00 nozzle: 2 | time: 1601540431.8664322 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:31.867435+00:00 nozzle: 0 | time: 1601540431.867435 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:31.867435+00:00 nozzle: 0 | time: 1601540431.867435 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:31.901433+00:00 nozzle: 2 | time: 1601540431.901433 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:31.902435+00:00 nozzle: 0 | time: 1601540431.902435 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:31.902435+00:00 nozzle: 0 | time: 1601540431.902435 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:31.963431+00:00 nozzle: 2 | time: 1601540431.9634316 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:31.964432+00:00 nozzle: 0 | time: 1601540431.9644318 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:31.965432+00:00 nozzle: 0 | time: 1601540431.9644318 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:32.009433+00:00 nozzle: 2 | time: 1601540432.009433 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:32.009433+00:00 nozzle: 2 | time: 1601540432.009433 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:32.010439+00:00 nozzle: 0 | time: 1601540432.0104396 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:32.011434+00:00 nozzle: 0 | time: 1601540432.0114346 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:32.056440+00:00 nozzle: 0 | time: 1601540432.05644 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:32.057434+00:00 nozzle: 0 | time: 1601540432.0574348 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:32.088434+00:00 nozzle: 0 | time: 1601540432.0884342 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:32.116431+00:00 nozzle: 0 | time: 1601540432.1164317 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:32.150433+00:00 nozzle: 0 | time: 1601540432.1504333 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:32.183437+00:00 nozzle: 0 | time: 1601540432.1834378 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:32.241431+00:00 nozzle: 0 | time: 1601540432.2414312 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:32.275432+00:00 nozzle: 0 | time: 1601540432.2754326 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:34.692432+00:00 nozzle: 3 | time: 1601540434.6924326 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:34.723437+00:00 nozzle: 3 | time: 1601540434.7234375 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:34.761434+00:00 nozzle: 3 | time: 1601540434.761434 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:34.800433+00:00 nozzle: 3 | time: 1601540434.8004332 | location 0 | delay: 0 | duration: 1
-2020-10-01 08:20:34.836438+00:00 nozzle: 3 | time: 1601540434.8354335 | location 0 | delay: 0 | duration: 1
diff --git a/logs/detections.jsonl b/logs/detections.jsonl
new file mode 100644
index 0000000..e69de29
diff --git a/logs/owl_system_logs.jsonl b/logs/owl_system_logs.jsonl
new file mode 100644
index 0000000..eaa8da6
--- /dev/null
+++ b/logs/owl_system_logs.jsonl
@@ -0,0 +1,12 @@
+{"timestamp": "2024-11-06 14:10:02,225", "level": "INFO", "logger": "__main__", "message": "Initializing OWL...", "module": "owl", "function": "__init__"}
+{"timestamp": "2024-11-06 14:10:02,227", "level": "INFO", "logger": "__main__", "message": "Starting OWL version 2.1.0", "module": "owl", "function": "_log_system_info"}
+{"timestamp": "2024-11-06 14:10:02,227", "level": "INFO", "logger": "__main__", "message": "System Information: OS: Raspbian 11 (bullseye)", "module": "owl", "function": "_log_system_info"}
+{"timestamp": "2024-11-06 14:10:02,227", "level": "INFO", "logger": "__main__", "message": "Python Version: 3.9.13", "module": "owl", "function": "_log_system_info"}
+{"timestamp": "2024-11-06 14:10:02,228", "level": "INFO", "logger": "SystemInfo", "message": "Raspberry Pi Model: 5, Revision: c03111", "module": "version", "function": "get_rpi_info"}
+{"timestamp": "2024-11-06 14:10:02,229", "level": "INFO", "logger": "SystemInfo", "message": "Git Commit: 4b825dc642cb6eb9a060e54bf8d69288fbee4904", "module": "version", "function": "get_git_info"}
+{"timestamp": "2024-11-06 14:10:02,230", "level": "INFO", "logger": "__main__", "message": "Git information retrieved successfully.", "module": "owl", "function": "_log_system_info"}
+{"timestamp": "2024-11-06 14:10:02,240", "level": "INFO", "logger": "__main__", "message": "Raspberry Pi version: 5", "module": "owl", "function": "__init__"}
+{"timestamp": "2024-11-06 14:10:02,338", "level": "INFO", "logger": "utils.output_manager", "message": "[INFO] Setting up nozzles...", "module": "output_manager", "function": "__init__"}
+{"timestamp": "2024-11-06 14:10:03,360", "level": "INFO", "logger": "utils.output_manager", "message": "[INFO] Nozzle setup complete. Initiating camera...", "module": "output_manager", "function": "__init__"}
+{"timestamp": "2024-11-06 14:10:03,374", "level": "WARNING", "logger": "__main__", "message": "High resolution, expect reduced framerate. Resolution set to 640x480.", "module": "owl", "function": "__init__"}
+{"timestamp": "2024-11-06 14:10:10,196", "level": "INFO", "logger": "__main__", "message": "[INFO] Stopped.", "module": "owl", "function": "hoot"}
diff --git a/models/README.md b/models/README.md
index 6dcf050..b52f0f4 100644
--- a/models/README.md
+++ b/models/README.md
@@ -1,19 +1,83 @@
-# Model directory for .tflite files
+# Adding Green-on-Green to the OWL (beta)
+Welcome to the first iteration of Green-on-Green or in-crop weed detection with the OWL. This is still an early beta version, so it may require additional troubleshooting. It has been tested and works on both a Raspberry Pi 4, LibreComputer and a Windows desktop computer.
-## Google Coral Installation - Raspberry Pi
-In addition to the other software installation to get the OpenWeedLocator running, you will also need to install the Google Coral supporting software onto the Raspberry Pi. Simply run `install_coral.sh` from the command line using the instructions below. Firstly, navigate to this directory with:
+## Stage 1| Hardware/Software - Google Coral Installation
+In addition to the other software installation to get the OpenWeedLocator running, you will also need to install the Google Coral supporting software onto the Raspberry Pi. Simply run `install_coral.sh` from the command line using the instructions below.
-`pi@raspberrypi:~ $ cd ~/owl/models`
+### Step 1
+Assuming you have cloned the OpenWeedLocator repository and renamed it to `owl`, navigate to the `models` directory on the Raspberry Pi with:
-Then, run the installation file:
+`owl@raspberrypi:~ $ cd ~/owl/models`
-`pi@raspberrypi:~ $ chmod +x install_coral.sh && ./install_coral.sh`.
+### Step 2
+Now run the installation file. This will install the `pycoral` library and other important packages to run the Coral. For full instructions on the installation process, we recommend reading the Google Coral [documentation](https://coral.ai/docs/accelerator/get-started/).
-During the installation, you will be asked to confirm options and connect the Google Coral USB to the USB3.0 ports (blue). For full instructions on the installation process, check out the Google Coral [documentation](https://coral.ai/docs/accelerator/get-started/).
+During the installation, you will be asked to confirm performance options and connect the Google Coral USB to the USB3.0 ports (blue).
-## Training/exporting detection models for inference with the Coral
-Once you have trained and exported your weed detection model (check out this notebook we have for [Weed-AI datasets](https://colab.research.google.com/github/Weed-AI/Weed-AI/blob/master/weed_ai_yolov5.ipynb)),
-you must export it using the command:
+`owl@raspberrypi:~ $ chmod +x install_coral.sh && ./install_coral.sh`.
+
+If you run into errors during the `pycoral` library installation, try running
+
+```
+owl@raspberrypi:~ $ workon owl
+(owl) owl@raspberrypi:~/owl/models$ pip install pycoral
+```
+
+### Step 3
+The final step is to test the installation.
+
+Open up a Python terminal by running:
+```
+(owl) owl@raspberrypi:~/owl/models$ python
+```
+
+Now try running:
+```
+>>> import pycoral
+```
+
+If this runs successfully then you're ready to move on to the next step and running object detection models with the OWL.
+
+## Stage 2 | Model Training/Deployment - Inference with the Coral
+Running weed recognition models on the Google Coral requires the generation of a .tflite model file. The .tflite files are specifically designed to be lightweight and efficient, making them well-suited for deployment on edge devices like the Coral USB TPU. One important thing to note is that .tflite files for the Google Coral are specifically optimized for it, so you cannot simply use any .tflite file. Using a generic .tflite file may result in much slower performance or even failure to run.
+
+This is an overview of the process from the official Google Coral documentation:
+
+
+### Step 1
+To test if the installation has worked, the recommended option is to download a generic model file first from the [Coral model repository](https://coral.ai/models/object-detection/). This will isolate any issues with it running to the OWL or the Google Coral installation, rather than the model training.
+
+While still in the `models` directory, run this command to download the appropriate model:
+```
+(owl) owl@raspberrypi:~/owl/models$ wget https://raw.githubusercontent.com/google-coral/test_data/master/ssd_mobilenet_v2_coco_quant_postprocess_edgetpu.tflite
+```
+
+Now change back to the `owl` directory and try running `owl.py` and specifying `gog` for the algorithm. If you don't specify a path to the `.tflite` model file, it will automatically select the first model in the directory when sorted alphabetically.
+
+**NOTE** If you are testing this inside, the camera settings will likely be too dark (and the image will appear entirely black) so you may also need to specify the `--exp-compensation 4` and `--exp-mode auto`.
+
+```
+(owl) owl@raspberrypi:~/owl/models$ cd ..
+(owl) owl@raspberrypi:~/owl$python owl.py --show-display --algorithm gog
+```
+
+If this runs correctly, a video feed just like the previous green-on-brown approach should appear with a red box around an 'object', which in this case has been filtered to only detect 'potted plants'. If you would like to detect any of the other COCO categories, simply change the `filter_id=63` to a different category. The full list is [available here](https://tech.amikelive.com/node-718/what-object-categories-labels-are-in-coco-dataset/).
+
+Once you have confirmed it is working, you will need to start training and deploying your own weed recognition models.
+
+There are two main ways to generate optimized, weed recognition .tflite files for the Coral. These are detailed below.
+
+### Option 1 | Train a model using Tensorflow
+These instructions by EdjeElectronics provide a step-by-step to a working .tflite Edge TPU model file.
+* [Google Colab walkthrough](https://colab.research.google.com/github/EdjeElectronics/TensorFlow-Lite-Object-Detection-on-Android-and-Raspberry-Pi/blob/master/Train_TFLite2_Object_Detction_Model.ipynb)
+* [Accompanying YouTube video](https://www.youtube.com/watch?v=XZ7FYAMCc4M&ab_channel=EdjeElectronics)
+
+There is also the [official Google Colab tutorial](https://colab.research.google.com/github/google-coral/tutorials/blob/master/retrain_ssdlite_mobiledet_qat_tf1.ipynb) from the Coral documentation, that walks you through the entire training process for custom datasets.
+
+### Train a YOLO v5/v8 model and export as .tflite
+** NOTE ** it appears this method isn't currently working consistently. Once this resolves, this will be the recommended approach, given the ease of training for YOLO models and the relatively high performance. You can track one of the issues on the Ultralytics repository [here](https://github.com/ultralytics/ultralytics/issues/1185).
+
+To train a YOLOv5 model from Weed-AI, check out this notebook we have for [Weed-AI datasets](https://colab.research.google.com/github/Weed-AI/Weed-AI/blob/master/weed_ai_yolov5.ipynb)). Once it is trained, you must export it using either of the following commands:
#### YOLOv5
`!python export.py --weights path/to/your/weights/best.pt --include edgetpu`
@@ -21,7 +85,7 @@ you must export it using the command:
`!yolo export model=path/to/your/weights/best.pt format=edgetpu`
The full explanation for each method is available in the [Ultralytics YOLOv5](https://github.com/ultralytics/yolov5)
-or [Ultralytics YOLOv8](https://github.com/ultralytics/ultralytics) repositories
+or [Ultralytics YOLOv8](https://github.com/ultralytics/ultralytics) repositories.
Currently, the `GreenOnGreen` class will simply either load the first (alphabetically) model in the directory if specified with
`algorithm='gog'` or will load the model specified if `algorithm=path/to/model.tflite`. Importantly, all your classes must
diff --git a/models/install_coral.sh b/models/install_coral.sh
index cab087f..c601315 100644
--- a/models/install_coral.sh
+++ b/models/install_coral.sh
@@ -41,3 +41,18 @@ done
echo "The pycoral library will now be installed."
sudo apt-get install python3-pycoral
+
+# Link the system wide installation to the OWL virtual environment
+# Find the directories containing pycoral and tflite
+PYCORAL_DIRS=$(find /usr/lib/python3/dist-packages -name "*pycoral*" -type d)
+TFLITE_DIRS=$(find /usr/lib/python3/dist-packages -name "*tflite*" -type d)
+
+# Find the site-packages directory of the virtual environment 'owl'
+OWL_SITE_PACKAGES=$(python -c "import site; print(site.getsitepackages()[0])" | grep owl | xargs)
+
+# Copy the directories containing pycoral and tflite to the site-packages directory
+for DIR in $PYCORAL_DIRS $TFLITE_DIRS; do
+ cp -r $DIR $OWL_SITE_PACKAGES
+done
+
+
diff --git a/models/labels.txt b/models/labels.txt
index dd71be4..19f69a7 100644
--- a/models/labels.txt
+++ b/models/labels.txt
@@ -1 +1 @@
-16 blue_lupin
\ No newline at end of file
+16 object
diff --git a/models/tf2_ssd_mobilenet_v2_coco17_ptq_edgetpu.tflite b/models/tf2_ssd_mobilenet_v2_coco17_ptq_edgetpu.tflite
new file mode 100644
index 0000000..894fd0d
Binary files /dev/null and b/models/tf2_ssd_mobilenet_v2_coco17_ptq_edgetpu.tflite differ
diff --git a/notes/README.md b/notes/README.md
new file mode 100644
index 0000000..8490f95
--- /dev/null
+++ b/notes/README.md
@@ -0,0 +1,35 @@
+# OWL Documentation Notes
+
+This directory contains detailed documentation for each component of the OpenWeedLocator (OWL) system.
+
+| File | Description | Last Updated |
+|--------------------------------------------------|-----------------------------------------------|--------------|
+| [owl.py](owl_py_notes.txt) | Main control script and system initialization | 10/11/2024 |
+| [error_manager.py](error_manager_py_notes.txt) | Error handling and management system | 10/11/2024 |
+| [video_manager.py](video_manager_py_notes.txt) | Camera handling and video stream management | 10/11/2024 |
+| [input_manager.py](input_manager_py_notes.txt) | Physical controls and GPIO management | 10/11/2024 |
+| [output_manager.py](output_manager_py_notes.txt) | Relay control and status indicators | 10/11/2024 |
+
+Each note file follows a consistent format:
+```
+################################################################################
+Notes on
+
+Summary completed on DD/MM/YYYY
+Summary based on commit XXXXXXX
+################################################################################
+
+Purpose:
+- Core functionality overview
+
+Classes:
+- List of classes
+
+[Detailed class documentation]
+- Methods
+- Attributes
+- Dependencies
+```
+
+These notes are maintained alongside code changes and are updated with each
+commit that modifies the corresponding file's functionality.
diff --git a/notes/error_manager_py_notes.txt b/notes/error_manager_py_notes.txt
new file mode 100644
index 0000000..535b930
--- /dev/null
+++ b/notes/error_manager_py_notes.txt
@@ -0,0 +1,105 @@
+################################################################################
+Notes on error_manager.py
+
+Summary completed on 14/11/2024
+Summary based on commit 95dfb6b
+################################################################################
+
+Purpose:
+- Handles all error management for OWL system
+- Provides colored terminal output for errors
+- Structures error hierarchy for consistent handling
+
+Class Hierarchy:
+- OWLError (Base Exception)
+ |- StorageError
+ |- USBError
+ |- USBMountError
+ |- USBWriteError
+ |- NoWritableUSBError
+ |- StorageSystemError
+ |- OWLProcessError
+ |- OWLAlreadyRunningError
+ |- OWLControllerError
+ |- ControllerPinError
+ |- ControllerConfigError
+ |- OWLConfigError
+ |- ConfigFileError
+ |- ConfigSectionError
+ |- ConfigKeyError
+ |- ConfigValueError
+ |- AlgorithmError
+ |- OpenCVError
+ |- DependencyError
+
+OWLError class:
+- Base exception for all OWL errors
+- Methods:
+ - __init__
+ - colorize
+ - format_error_header
+ - format_section
+
+OWLError class --> __init__ method:
+- Takes message and details dictionary
+- Sets timestamp and error ID
+- Initializes base Exception
+
+OWLError class --> colorize method:
+- Takes text, color, bold flag, underline flag
+- Returns ANSI-colored string for terminal output
+
+StorageError classes:
+- USBMountError: USB device mounting failures
+- USBWriteError: USB write permission issues
+- NoWritableUSBError: No available USB storage
+- StorageSystemError: Platform compatibility issues
+
+OWLProcessError classes:
+- OWLAlreadyRunningError: Handles duplicate instances
+- Methods:
+ - get_owl_processes: Lists running OWL instances
+
+OWLControllerError classes:
+- ControllerPinError: GPIO pin configuration issues
+- ControllerConfigError: Controller setup problems
+
+OWLConfigError classes:
+- ConfigFileError: Missing/invalid config file
+- ConfigSectionError: Missing required sections
+- ConfigKeyError: Missing required keys
+- ConfigValueError: Invalid configuration values
+
+AlgorithmError class:
+- Handles detection algorithm failures
+- Methods:
+ - handle: Logs error and stops OWL
+- Predefined messages for:
+ - ModuleNotFoundError
+ - IndexError/FileNotFoundError
+ - ValueError
+
+OpenCVError class:
+- OpenCV import/initialization failures
+- Methods:
+ - handle: Logs error and exits
+
+DependencyError class:
+- Python package dependency issues
+- Methods:
+ - _format_pip_package_error
+ - _format_local_file_error
+ - handle
+
+Error Display Features:
+- Colored terminal output
+- Standardized error headers
+- Formatted error sections
+- Timestamped error IDs
+- Detailed error messages
+
+Dependencies:
+- subprocess: Process management
+- logging: Error logging
+- pathlib: Path handling
+- datetime: Timestamp generation
diff --git a/notes/input_manager_py_notes.txt b/notes/input_manager_py_notes.txt
new file mode 100644
index 0000000..ef0cd2e
--- /dev/null
+++ b/notes/input_manager_py_notes.txt
@@ -0,0 +1,109 @@
+################################################################################
+Notes on input_manager.py
+
+Summary completed on 14/11/2024
+Summary based on commit 95dfb6b
+################################################################################
+
+Purpose:
+- Manages physical control interfaces for OWL
+- Handles GPIO button inputs
+- Controls detection and recording states
+- Provides platform compatibility checks
+
+Functions:
+- is_raspberry_pi
+- get_rpi_version
+
+Classes:
+- UteController
+- AdvancedController
+
+UteController class:
+- Single-switch control interface
+- Methods:
+ - __init__
+ - update_state
+ - toggle_state
+ - weed_detect_indicator
+ - image_write_indicator
+ - run
+ - stop
+
+UteController class --> __init__ method:
+- Takes detection_state, sample_state, stop_flag
+- Takes owl_instance, status_indicator
+- Takes switch_purpose (recording/detection)
+- Takes switch_board_pin, bounce_time
+- Initializes GPIO button handlers
+
+UteController class --> update_state method:
+- Checks switch position
+- Updates detection or recording state
+- Toggles LED indicators
+- Controls weed detection/image sampling
+
+AdvancedController class:
+- Multi-switch interface for advanced control
+- Methods:
+ - __init__
+ - update_state
+ - update_recording_state
+ - update_sensitivity_state
+ - update_sensitivity_settings
+ - set_detection_mode
+ - update_detection_mode_state
+ - weed_detect_indicator
+ - image_write_indicator
+ - run
+ - stop
+ - _read_config
+
+AdvancedController class --> __init__ method:
+- Takes recording/sensitivity/detection states
+- Takes owl_instance, status_indicator
+- Takes config paths for sensitivity settings
+- Takes button pin mappings and bounce time
+- Initializes multiple GPIO buttons
+- Sets up button event handlers
+
+AdvancedController class --> update_state method:
+- Updates all control states:
+ * Recording state
+ * Sensitivity state
+ * Detection mode state
+
+AdvancedController class --> set_detection_mode method:
+- Takes mode parameter (0=detect, 1=off, 2=all on)
+- Controls relay states
+- Updates status indicators
+
+Platform Handling:
+- Detects Raspberry Pi platform
+- Provides GPIO access on Pi
+- Testing mode on non-Pi platforms
+- Version detection for Pi models
+
+Dependencies:
+- gpiozero: GPIO control (Pi only)
+- configparser: Config file reading
+- cv2: GUI trackbar updates
+- threading: Process control
+- logging: Status messages
+
+GPIO Configuration:
+- Button setup with bounce time
+- Event handler registration
+- State management
+- LED indicator control
+
+Thread Safety:
+- State locking for shared variables
+- Safe process termination
+- Event-based status updates
+
+Error Handling:
+- Platform compatibility checks
+- GPIO initialization errors
+- Config file reading errors
+- Process management safety
diff --git a/notes/output_manager_py_notes.txt b/notes/output_manager_py_notes.txt
new file mode 100644
index 0000000..2e78dec
--- /dev/null
+++ b/notes/output_manager_py_notes.txt
@@ -0,0 +1,171 @@
+################################################################################
+Notes on output_manager.py
+
+Summary completed on 14/11/2024
+Summary based on commit 95dfb6b
+################################################################################
+
+Purpose:
+- Controls hardware outputs for OWL system
+- Manages relay board control and status indicators
+- Provides test interfaces for non-Pi development
+
+Classes:
+- TestRelay
+- TestBuzzer
+- TestLED
+- BaseStatusIndicator
+- HeadlessStatusIndicator
+- UteStatusIndicator
+- AdvancedStatusIndicator
+- RelayControl
+- RelayController
+
+Entrypoint:
+- Has __main__ check for testing status indicators
+- Sets testing flag via get_platform_config() if not on Raspberry Pi
+- Uses terminal messages instead of GPIO when in test mode
+
+TestRelay class:
+- Simulates relay hardware for testing
+- Methods:
+ - __init__
+ - on
+ - off
+
+TestRelay class --> __init__ method:
+- Takes relay_number and verbose flag
+- Stores them as instance attributes
+
+TestRelay class --> on method:
+- Prints "[TEST] Relay {number} ON" if verbose enabled
+
+TestRelay class --> off method:
+- Prints "[TEST] Relay {number} OFF" if verbose enabled
+
+TestBuzzer class:
+- Simulates buzzer hardware for testing
+- Methods:
+ - beep
+
+TestBuzzer class --> beep method:
+- Takes on_time, off_time, n repeats, verbose flag
+- Prints "BEEP" n times if verbose enabled
+
+TestLED class:
+- Simulates LED hardware for testing
+- Methods:
+ - __init__
+ - blink
+ - on
+ - off
+
+TestLED class --> __init__ method:
+- Takes pin number
+- Stores as instance attribute
+
+TestLED class --> blink method:
+- Takes on_time, off_time, n repeats, verbose flag
+- Prints "BLINK {pin}" n times if verbose enabled
+
+TestLED class --> on/off methods:
+- Print "LED {pin} ON/OFF" respectively
+
+BaseStatusIndicator class:
+- Base class for all status indicators
+- Handles storage monitoring and LED control
+- Methods:
+ - __init__
+ - start_storage_indicator
+ - run_update
+ - update
+ - error
+ - stop
+
+BaseStatusIndicator class --> __init__ method:
+- Takes save_directory and no_save flag
+- Initializes storage monitoring
+- Sets up LED control threads
+- Configures system LEDs
+
+BaseStatusIndicator class --> update method:
+- Monitors storage usage
+- Updates LED states based on storage
+- Triggers errors if storage full
+
+HeadlessStatusIndicator class:
+- Minimal implementation without physical indicators
+- Inherits from BaseStatusIndicator
+- Only monitors storage capacity
+- Methods:
+ - __init__
+ - _update_storage_indicator
+
+UteStatusIndicator class:
+- Two-LED indicator system
+- Inherits from BaseStatusIndicator
+- Methods:
+ - __init__
+ - _update_storage_indicator
+ - setup_success
+ - image_write_indicator
+ - error
+ - stop
+
+UteStatusIndicator class --> __init__ method:
+- Takes save_directory and LED pin numbers
+- Initializes record and storage LEDs
+- Sets up status monitoring
+
+UteStatusIndicator class --> _update_storage_indicator method:
+- Takes percent_full value
+- Changes LED blink patterns based on storage level:
+ * >90%: Solid storage LED
+ * >85%: Fast blink
+ * >80%: Medium blink
+ * >75%: Slow blink
+ * >50%: Very slow blink
+ * <50%: Extremely slow blink
+
+RelayControl class:
+- Direct hardware interface for relay board
+- Methods:
+ - __init__
+ - relay_on
+ - relay_off
+ - beep
+ - all_on
+ - all_off
+ - remove
+ - clear
+ - stop
+
+RelayControl class --> __init__ method:
+- Takes relay_dict mapping relays to GPIO pins
+- Initializes buzzer on BOARD7
+- Creates OutputDevice for each relay
+- Sets up test devices if not on Pi
+
+RelayController class:
+- Manages relay timing and job queues
+- Methods:
+ - __init__
+ - receive
+ - consumer
+
+RelayController class --> __init__ method:
+- Takes relay_dict and visualization flags
+- Creates job queues for each relay
+- Initializes threading conditions
+- Starts consumer threads
+
+RelayController class --> receive method:
+- Takes relay, timestamp, location, delay, duration
+- Queues spray job for specified relay
+- Notifies consumer thread
+
+RelayController class --> consumer method:
+- Runs in separate thread for each relay
+- Processes queued spray jobs
+- Manages timing and relay states
+- Coordinates with visualization system
diff --git a/notes/owl_py_notes.txt b/notes/owl_py_notes.txt
new file mode 100644
index 0000000..233029f
--- /dev/null
+++ b/notes/owl_py_notes.txt
@@ -0,0 +1,98 @@
+################################################################################
+Notes on owl.py
+
+Summary completed on 10/11/2024
+Summary based on commit: 95dfb6b
+################################################################################
+
+Purpose:
+- Primary control script for OWL system
+- Handles real-time weed detection and sprayer control
+- Manages configuration, data collection, and hardware interfaces
+
+Entrypoint:
+- Validates Python environment and imports
+- Parses command line arguments:
+ - --show-display: Enable visualization windows
+ - --focus: Add blur detection to output
+ - --input: Path to input media (image/video/directory)
+- Creates Owl instance with parsed arguments
+- Starts detection loop via owl.hoot()
+
+Owl class:
+Main methods:
+- __init__: System initialization
+- hoot: Main detection loop
+- stop: Graceful shutdown
+- save_parameters: Save current settings
+- _log_system_info: Record system details
+
+Initialization (__init__):
+1. Configuration
+ - Validates config file
+ - Sets up logging
+ - Initializes detection parameters
+ - Creates visualization GUI if enabled
+
+2. Hardware Setup
+ - Configures camera (resolution/exposure)
+ - Maps GPIO pins to relays
+ - Sets up controller (None/Ute/Advanced)
+ - Initializes USB storage for data collection
+
+3. Detection Setup
+ - Configures spray zones and trigger lines
+ - Sets initial algorithm parameters
+ - Validates hardware capabilities
+
+Main Loop (hoot):
+1. Frame Processing:
+ - Acquires frame from camera/file
+ - Updates detection parameters
+ - Runs weed detection algorithm
+
+2. Detection Response:
+ - Maps detected weeds to spray zones
+ - Triggers appropriate relays
+ - Updates visualization
+
+3. Data Collection:
+ - Records frames/regions based on config
+ - Manages storage limits
+ - Logs performance metrics
+
+4. User Interface:
+ - Processes keyboard input
+ - Updates display windows
+ - Handles recording controls
+
+Error Handling:
+- Validates Python environment
+- Checks hardware compatibility
+- Manages GPIO conflicts
+- Handles storage issues
+- Reports algorithm errors
+
+Dependencies:
+Core:
+- OpenCV-Python: Image processing
+- NumPy: Array operations
+- imutils: Image utilities
+
+Custom modules:
+- error_manager: Error handling
+- input_manager: Hardware control
+- config_manager: Configuration
+- video_manager: Camera interface
+- image_sampler: Data collection
+- algorithms: Detection methods
+
+Configuration:
+- Uses .ini format
+- Validated sections:
+ - System: Core parameters
+ - Camera: Image acquisition
+ - Controller: Hardware interface
+ - DataCollection: Storage settings
+ - GreenOnBrown: Detection parameters
+ - Relays: GPIO mappings
diff --git a/notes/video_manager_py_notes.txt b/notes/video_manager_py_notes.txt
new file mode 100644
index 0000000..f10c792
--- /dev/null
+++ b/notes/video_manager_py_notes.txt
@@ -0,0 +1,111 @@
+################################################################################
+Notes on video_manager.py
+
+Summary completed on 14/11/2024
+Summary based on commit 95dfb6b
+################################################################################
+
+Purpose:
+- Manages different camera types for video capture
+- Supports Picamera (legacy), Picamera2, and standard webcams
+- Provides thread-safe video streaming
+
+Classes:
+- WebcamStream
+- PiCamera2Stream
+- PiCameraStream
+- VideoStream
+
+WebcamStream class:
+- Handles USB/standard webcams via OpenCV
+- Methods:
+ - __init__
+ - start
+ - update
+ - read
+ - stop
+
+WebcamStream class --> __init__ method:
+- Takes camera source number
+- Initializes VideoCapture stream
+- Sets frame dimensions
+- Creates thread for frame updates
+
+WebcamStream class --> update method:
+- Runs in separate thread
+- Continuously reads frames
+- Updates frame buffer
+- Handles stream closure
+
+PiCamera2Stream class:
+- Handles newer Raspberry Pi cameras
+- Methods:
+ - __init__
+ - start
+ - update
+ - read
+ - stop
+
+PiCamera2Stream class --> __init__ method:
+- Takes resolution, exposure compensation
+- Configures camera parameters
+- Sets up thread synchronization
+- Detects camera model (imx296/imx477/imx708)
+
+PiCamera2Stream class --> update method:
+- Thread-safe frame capture
+- Uses condition/lock for synchronization
+- Handles camera cleanup
+
+PiCameraStream class:
+- Handles legacy Raspberry Pi camera
+- Methods:
+ - __init__
+ - start
+ - update
+ - read
+ - stop
+
+PiCameraStream class --> __init__ method:
+- Takes resolution, exposure compensation
+- Sets PiCamera parameters
+- Initializes frame buffer
+- Creates capture stream
+
+PiCameraStream class --> update method:
+- Continuous frame capture
+- Updates frame buffer
+- Handles resource cleanup
+
+VideoStream class:
+- Factory class to create appropriate stream
+- Methods:
+ - __init__
+ - start
+ - update
+ - read
+ - stop
+
+VideoStream class --> __init__ method:
+- Detects available camera version
+- Creates appropriate stream object
+- Sets frame dimensions
+- Handles initialization errors
+
+Dependencies:
+- cv2: OpenCV camera interface
+- picamera/picamera2: Raspberry Pi cameras
+- threading: Thread management
+- logging: Status and error logging
+
+Thread Safety:
+- Lock for frame buffer access
+- Condition for frame synchronization
+- Event for thread control
+- Daemon threads for cleanup
+
+Error Handling:
+- Camera initialization failures
+- Stream read errors
+- Resource cleanup on errors
+- Camera version compatibility
diff --git a/owl.py b/owl.py
old mode 100644
new mode 100755
index 4661f63..87b8a35
--- a/owl.py
+++ b/owl.py
@@ -1,547 +1,764 @@
-#!/home/pi/.virtualenvs/owl/bin/python3
-import numpy as np
-
-from algorithms import exg, exg_standardised, exg_standardised_hue, hsv, exgr, gndvi, maxg
-from button_inputs import Recorder
-from image_sampler import bounding_box_image_sample, square_image_sample, whole_image_save
-from greenonbrown import GreenOnBrown
-from greenongreen import GreenOnGreen
-from datetime import datetime, timezone
-from imutils.video import VideoStream, FileVideoStream, FPS
-from relay_control import Controller
-from queue import Queue
-from time import strftime
-from threading import Thread
-import subprocess
-import argparse
-import imutils
-import shutil
-import json
-import time
-import sys
-import cv2
+#!/usr/bin/env python
import os
+import sys
+import logging
+import argparse
+import time
+from datetime import datetime
+from multiprocessing import Process, Value
+from pathlib import Path
+
+def get_python_env():
+ """Get current Python environment status"""
+ venv = os.environ.get('VIRTUAL_ENV')
+ if venv:
+ return f"Virtual environment: {venv}"
+ return "No virtual environment active (using system Python)"
+
+def setup_basic_logger():
+ """Simple startup logger that uses the same file as LogManager"""
+ log_dir = Path(os.getcwd()) / 'logs'
+ log_dir.mkdir(exist_ok=True)
+
+ file_handler = logging.FileHandler(log_dir / 'owl.jsonl')
+ file_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
+ console_handler = logging.StreamHandler(sys.stdout)
+ console_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
+
+ root_logger = logging.getLogger()
+ root_logger.setLevel(logging.INFO)
+ root_logger.addHandler(file_handler)
+ root_logger.addHandler(console_handler)
+
+ return logging.getLogger('owl_startup')
+
+logger = setup_basic_logger()
+logger.info("Starting OWL - checking imports...")
+
+try:
+ import utils.error_manager as errors
+except ImportError:
+ logger.critical("Cannot import from utils package! Not in correct directory.")
+ logger.critical(f"Current working directory: {os.getcwd()}")
+ print("\nERROR: Cannot import from utils package!")
+ print("This usually means you are not in the correct directory.")
+ print("\nTo fix:")
+ print("1. Ensure owl environment is active: workon owl")
+ print("2. Navigate to owl directory: cd /home/owl/owl")
+ sys.exit(1)
+
+try:
+ import cv2
+except ImportError as e:
+ logger.error("OpenCV import failed - likely not in `owl` virtual environment")
+ logger.error(f"Error details: {str(e)}")
+ logger.error(f"Python environment: {get_python_env()}")
+ raise errors.OpenCVError(str(e)) from None
+
+try:
+ import imutils
+ from imutils.video import FPS
+
+ from utils.input_manager import UteController, AdvancedController, get_rpi_version
+ from utils.output_manager import RelayController, HeadlessStatusIndicator, UteStatusIndicator, AdvancedStatusIndicator
+ from utils.directory_manager import DirectorySetup
+ from utils.video_manager import VideoStream
+ from utils.image_sampler import ImageRecorder
+ from utils.algorithms import fft_blur
+ from utils.greenonbrown import GreenOnBrown
+ from utils.frame_reader import FrameReader
+ from utils.config_manager import ConfigValidator
+ from utils.log_manager import LogManager
+ import utils.error_manager as errors
+ from version import SystemInfo, VERSION
+
+except ImportError as e:
+ missing_module = str(e).split("'")[1]
+ logger.error(f"Failed to import required module: {missing_module}")
+ logger.error(f"Error details: {str(e)}")
+ logger.error(f"Current virtual env: {os.environ.get('VIRTUAL_ENV', 'None')}")
+ logger.error(f"Current working directory: {os.getcwd()}")
+ raise errors.DependencyError(missing_module, str(e)) from None
+
+logger.info("All required modules imported successfully")
def nothing(x):
pass
-
class Owl:
- def __init__(self,
- videoFile=None,
- show_display=False,
- recording=False,
- nozzleNum=4,
- exgMin=30,
- exgMax=180,
- hueMin=30,
- hueMax=92,
- brightnessMin=5,
- brightnessMax=200,
- saturationMin=30,
- saturationMax=255,
- resolution=(416, 320),
- framerate=32,
- exp_mode='sports',
- awb_mode='auto',
- sensor_mode=0,
- exp_compensation=-4,
- parameters_json=None):
-
- # different detection parameters
+ def __init__(self, show_display=False,
+ focus=False,
+ input_file_or_directory=None,
+ config_file='config/DAY_SENSITIVITY_2.ini'):
+ # set up the logger
+ log_dir = Path(os.path.join(os.path.dirname(__file__), 'logs'))
+ LogManager.setup(log_dir=log_dir, log_level='INFO')
+ self.logger = LogManager.get_logger(__name__)
+
+ self.logger.info("Initializing OWL...")
+ self._log_system_info()
+
+ # read the config file
+ self._config_path = Path(__file__).parent / config_file
+ try:
+ self.config = ConfigValidator.load_and_validate_config(self._config_path)
+ except errors.OWLConfigError as e:
+ self.logger.error(f"Configuration error: {e}", exc_info=True)
+ raise
+
+ self.config.read(self._config_path)
+ self.RPI_VERSION = get_rpi_version()
+ self.logger.info(msg=f'Raspberry Pi version: {self.RPI_VERSION}')
+
+ # is the source a directory/file
+ self.input_file_or_directory = input_file_or_directory
+
+ # visualise the detections with video feed
self.show_display = show_display
- self.recording = recording
- self.resolution = resolution
- self.framerate = framerate
- self.exp_mode = exp_mode
- self.awb_mode = awb_mode
- self.sensor_mode = sensor_mode
- self.exp_compensation = exp_compensation
+ self.focus = focus
+
+ if self.focus:
+ self.show_display = True
# threshold parameters for different algorithms
- self.exgMin = exgMin
- self.exgMax = exgMax
- self.hueMin = hueMin
- self.hueMax = hueMax
- self.saturationMin = saturationMin
- self.saturationMax = saturationMax
- self.brightnessMin = brightnessMin
- self.brightnessMax = brightnessMax
-
- self.thresholdDict = {}
-
- if parameters_json:
- try:
- with open(parameters_json) as f:
- self.thresholdDict = json.load(f)
- self.exgMin = self.thresholdDict['exgMin']
- self.exgMax = self.thresholdDict['exgMax']
- self.hueMin = self.thresholdDict['hueMin']
- self.hueMax = self.thresholdDict['hueMax']
- self.saturationMin = self.thresholdDict['saturationMin']
- self.saturationMax = self.thresholdDict['saturationMax']
- self.brightnessMin = self.thresholdDict['brightnessMin']
- self.brightnessMax = self.thresholdDict['brightnessMax']
- print('[INFO] Parameters successfully loaded.')
-
- except FileExistsError:
- print('[ERROR] Parameters file not found. Continuing with default settings.')
-
- except KeyError:
- print('[ERROR] Parameter key not found. Continuing with default settings.')
+ self.exg_min = self.config.getint('GreenOnBrown', 'exg_min')
+ self.exg_max = self.config.getint('GreenOnBrown', 'exg_max')
+ self.hue_min = self.config.getint('GreenOnBrown', 'hue_min')
+ self.hue_max = self.config.getint('GreenOnBrown', 'hue_max')
+ self.saturation_min = self.config.getint('GreenOnBrown', 'saturation_min')
+ self.saturation_max = self.config.getint('GreenOnBrown', 'saturation_max')
+ self.brightness_min = self.config.getint('GreenOnBrown', 'brightness_min')
+ self.brightness_max = self.config.getint('GreenOnBrown', 'brightness_max')
+
+ # time spent on each image when looping over a directory
+ self.image_loop_time = self.config.getint('Visualisation', 'image_loop_time')
# setup the track bars if show_display is True
if self.show_display:
# create trackbars for the threshold calculation
self.window_name = "Adjust Detection Thresholds"
cv2.namedWindow("Adjust Detection Thresholds", cv2.WINDOW_AUTOSIZE)
- cv2.createTrackbar("ExG-Min", self.window_name, self.exgMin, 255, nothing)
- cv2.createTrackbar("ExG-Max", self.window_name, self.exgMax, 255, nothing)
- cv2.createTrackbar("Hue-Min", self.window_name, self.hueMin, 179, nothing)
- cv2.createTrackbar("Hue-Max", self.window_name, self.hueMax, 179, nothing)
- cv2.createTrackbar("Sat-Min", self.window_name, self.saturationMin, 255, nothing)
- cv2.createTrackbar("Sat-Max", self.window_name, self.saturationMax, 255, nothing)
- cv2.createTrackbar("Bright-Min", self.window_name, self.brightnessMin, 255, nothing)
- cv2.createTrackbar("Bright-Max", self.window_name, self.brightnessMax, 255, nothing)
-
- # nozzleDict maps the reference nozzle number to a boardpin on the embedded device
- self.nozzleDict = {
- 0: 13,
- 1: 15,
- 2: 16,
- 3: 18
- }
-
- # instantiate the nozzle controller - successful start should beep the buzzer
- self.controller = Controller(nozzleDict=self.nozzleDict)
-
- # instantiate the logger
- self.logger = self.controller.logger
-
- # check that the resolution is not so high it will entirely brick/destroy the OWL.
- total_pixels = resolution[0] * resolution[1]
- if total_pixels > (832 * 640):
- # change here if you want to test higher resolutions, but be warned, backup your current image!
- self.resolution = (416, 320)
- self.logger.log_line('[WARNING] Resolution {} selected is dangerously high. '
- 'Resolution has been reset to default to avoid damaging the OWL'.format(resolution),
- verbose=True)
+ cv2.createTrackbar("ExG-Min", self.window_name, self.exg_min, 255, nothing)
+ cv2.createTrackbar("ExG-Max", self.window_name, self.exg_max, 255, nothing)
+ cv2.createTrackbar("Hue-Min", self.window_name, self.hue_min, 179, nothing)
+ cv2.createTrackbar("Hue-Max", self.window_name, self.hue_max, 179, nothing)
+ cv2.createTrackbar("Sat-Min", self.window_name, self.saturation_min, 255, nothing)
+ cv2.createTrackbar("Sat-Max", self.window_name, self.saturation_max, 255, nothing)
+ cv2.createTrackbar("Bright-Min", self.window_name, self.brightness_min, 255, nothing)
+ cv2.createTrackbar("Bright-Max", self.window_name, self.brightness_max, 255, nothing)
+
+ self.resolution = (self.config.getint('Camera', 'resolution_width'),
+ self.config.getint('Camera', 'resolution_height'))
+ self.exp_compensation = self.config.getint('Camera', 'exp_compensation')
+
+ # Relay Dict maps the reference relay number to a boardpin on the embedded device
+ self.relay_dict = {}
+
+ # use the [Relays] section to build the dictionary
+ for key, value in self.config['Relays'].items():
+ self.relay_dict[int(key)] = int(value)
+
+ # instantiate the relay controller - successful start should beep the buzzer
+ try:
+ self.relay_controller = RelayController(relay_dict=self.relay_dict)
+ except errors.OWLAlreadyRunningError:
+ self.logger.critical("OWL initialization failed: GPIO pin conflict. Another OWL instance may be running.",
+ exc_info=True)
+ raise
- # instantiate the recorder if recording is True
- if self.recording:
- self.fourcc = cv2.VideoWriter_fourcc(*'MJPG')
- self.writer = None
+ ### Data collection only ###
+ # WARNING: initialise option disable detection for data collection
+ self.disable_detection = False
+ self.save_directory = None
+ # if a controller is connected, sample images must be true to set up directories correctly
+ self.controller_type = self.config.get('Controller', 'controller_type').strip("'\" ").lower()
+
+ if self.controller_type not in {'none', 'ute', 'advanced'}:
+ self.logger.error(f"Invalid controller type: {self.controller_type}")
+ raise errors.ControllerTypeError(self.config.get('Controller', 'controller_type'))
+
+ if self.controller_type != 'none':
+ self.sample_images = True
else:
- self.record = False
- self.saveRecording = False
-
- # check if test video or videostream from camera
- if videoFile:
- self.cam = FileVideoStream(videoFile).start()
- frame_width = self.cam.stream.get(cv2.CAP_PROP_FRAME_WIDTH)
- frame_height = self.cam.stream.get(cv2.CAP_PROP_FRAME_HEIGHT)
- self.logger.log_line(f'[INFO] Using video {videoFile}...', verbose=True)
- # if no video, start the camera with the provided parameters
- else:
- try:
- self.cam = VideoStream(usePiCamera=True,
- resolution=self.resolution,
- framerate=self.framerate,
- exposure_mode=self.exp_mode,
- awb_mode=self.awb_mode,
- sensor_mode=self.sensor_mode,
- exposure_compensation=self.exp_compensation).start()
- frame_width = self.resolution[0] #
- frame_height = self.resolution[1] #
-
- # save camera settings to the log
- self.logger.log_line('[INFO] Camera setup complete. Settings: '
- '\nResolution: {}'
- '\nFramerate: {}'
- '\nExposure Mode: {}'
- '\nAutoWhiteBalance: {}'
- '\nExposure Compensation: {}'
- '\nSensor Mode: {}'.format(self.resolution,
- self.framerate,
- self.exp_mode,
- self.awb_mode,
- self.exp_compensation,
- self.sensor_mode), verbose=True)
-
-
-
- except ModuleNotFoundError:
- self.cam = VideoStream(src=0).start()
- frame_width = self.cam.stream.get(cv2.CAP_PROP_FRAME_WIDTH)
- frame_height = self.cam.stream.get(cv2.CAP_PROP_FRAME_HEIGHT)
- self.logger.log_line('[INFO] Camera setup complete. Using inbuilt webcam...')
-
- time.sleep(2.0)
-
-
- # set the sprayqueue size
- self.sprayQueue = Queue(maxsize=10)
+ self.sample_images = self.config.getboolean('DataCollection', 'sample_images')
- ### Data collection only ###
- # this is where a recording button can be added. Currently set to pin 37
- if self.recording:
- self.recorderButton = Recorder(recordGPIO=37)
+ # if controller is 'none' but sample_images is True, then it will set it up still
+ if self.sample_images:
+ self.sample_method = self.config.get('DataCollection', 'sample_method')
+ self.disable_detection = self.config.getboolean('DataCollection', 'disable_detection')
+ self.sample_frequency = self.config.getint('DataCollection', 'sample_frequency')
+ self.save_directory = self.config.get('DataCollection', 'save_directory')
+ self.camera_name = self.config.get('DataCollection', 'camera_name')
+
+ self.directory_manager = DirectorySetup(save_directory=self.save_directory)
+ self.save_directory, self.save_subdirectory = self.directory_manager.setup_directories()
+
+ self.image_recorder = ImageRecorder(save_directory=self.save_subdirectory, mode=self.sample_method)
############################
+ # initialise controller buttons and async management
+ if self.controller_type != 'none':
+ self.detection_state = Value('b', False)
+ self.sample_state = Value('b', False)
+ self.stop_flag = Value('b', False)
+
+ # 'ute controller' that fits in a cupholder. Only one switch to toggle recording OR detection on/off.
+ if self.controller_type == 'ute':
+ self.status_indicator = UteStatusIndicator(
+ save_directory=self.save_directory,
+ record_led_pin='BOARD38',
+ storage_led_pin='BOARD40')
+
+ self.switch_purpose = self.config.get('Controller', 'switch_purpose').strip("'\" ").lower()
+ self.switch_pin = self.config.getint('Controller', 'switch_pin')
+
+ self.controller = UteController(
+ detection_state=self.detection_state,
+ sample_state=self.sample_state,
+ stop_flag=self.stop_flag,
+ owl_instance=self,
+ status_indicator=self.status_indicator,
+ switch_board_pin=f'BOARD{self.switch_pin}',
+ switch_purpose=self.switch_purpose
+ )
+
+ # The 'advanced' controller. Controls multiple inputs.
+ elif self.controller_type == 'advanced':
+ self.status_indicator = AdvancedStatusIndicator(save_directory=self.save_directory,
+ status_led_pin='BOARD37')
+
+ self.sensitivity_state = Value('b', False)
+ self.detection_mode_state = Value('i', 1) # Default to off (1)
+
+ recording_pin = self.config.getint('Controller', 'recording_pin')
+ sensitivity_pin = self.config.getint('Controller', 'sensitivity_pin')
+ detection_mode_pin_up = self.config.getint('Controller', 'detection_mode_pin_up')
+ detection_mode_pin_down = self.config.getint('Controller', 'detection_mode_pin_down')
+ low_sensitivity_config = self.config.get('Controller', 'low_sensitivity_config').strip("'\" ")
+ high_sensitivity_config = self.config.get('Controller', 'high_sensitivity_config').strip("'\" ")
+
+ self.controller = AdvancedController(
+ recording_state=self.sample_state,
+ sensitivity_state=self.sensitivity_state,
+ detection_mode_state=self.detection_mode_state,
+ stop_flag=self.stop_flag,
+ owl_instance=self,
+ status_indicator=self.status_indicator,
+ low_sensitivity_config=low_sensitivity_config,
+ high_sensitivity_config=high_sensitivity_config,
+ recording_bpin=f'BOARD{recording_pin}',
+ sensitivity_bpin=f'BOARD{sensitivity_pin}',
+ detection_mode_bpin_up=f'BOARD{detection_mode_pin_up}',
+ detection_mode_bpin_down=f'BOARD{detection_mode_pin_down}'
+ )
+
+ else:
+ raise ValueError(f"Invalid controller type: {self.controller_type}. "
+ f"Select from None, Advanced or Ute in the config file.")
+
+ self.controller_process = Process(target=self.controller.run)
+ self.controller_process.start()
+
+ else:
+ self.controller = None
+ if self.sample_images:
+ self.status_indicator = HeadlessStatusIndicator(save_directory=self.save_directory)
+ self.status_indicator.start_storage_indicator()
+
+ else:
+ self.status_indicator = HeadlessStatusIndicator(save_directory=None, no_save=True)
+
+ self.relay_vis = None
+
+ # Check which Raspberry Pi is being used and adjust the resolution accordingly.
+ # Use `cat /proc-device-tree/model` to check the model of the Raspberry Pi.
+ total_pixels = self.resolution[0] * self.resolution[1]
+
+ if (self.RPI_VERSION in ['rpi-3', 'rpi-4']) and total_pixels > (832 * 640):
+ # change here if you want to test higher resolutions, but be warned, backup your current image!
+ # the older versions of the Pi are known to 'brick' and become unusable if too high resolutions are used.
+ self.resolution = (640, 480)
+ self.logger.warning(f"Resolution {self.config.getint('Camera', 'resolution_width')}, "
+ f"{self.config.getint('Camera', 'resolution_height')} selected is dangerously high. ")
+ else:
+ self.logger.warning(f'High resolution, expect low framerate. Resolution set to {self.resolution[0]}x{self.resolution[1]}.')
+
+ self.frame_width = None
+ self.frame_height = None
+
+ try:
+ self.cam = self.setup_media_source(input_file_or_directory)
+ self.logger.info('Media source successfully set up...')
+ time.sleep(1.0)
+
+ except (errors.MediaPathError, errors.InvalidMediaError, errors.MediaInitError, errors.CameraInitError) as e:
+ self.logger.error(str(e))
+ self.stop()
+
# sensitivity and weed size to be added
self.sensitivity = None
- self.laneCoords = {}
+ self.lane_coords = {}
- # add the total number of nozzles. This can be changed easily, but the nozzleDict and physical relays would need
+ # add the total number of relays being controlled. This can be changed easily, but the relay_dict and physical relays would need
# to be updated too. Fairly straightforward, so an opportunity for more precise application
- self.nozzleNum = nozzleNum
+ self.relay_num = self.config.getint('System', 'relay_num')
- # activation region limit - once weed crosses this line, nozzle is activated
- self.yAct = int((0.2) * frame_height)
- self.laneWidth = frame_width / self.nozzleNum
+ # activation region limit - once weed crosses this line, relay is activated
+ self.yAct = int(0.01 * self.frame_height)
+ self.lane_width = self.frame_width / self.relay_num
# calculate lane coords and draw on frame
- for i in range(self.nozzleNum):
- laneX = int(i * self.laneWidth)
- self.laneCoords[i] = laneX
-
- self.nozzle_vis = self.controller.nozzle_vis
- self.nozzle_vis.setup()
- self.controller.vis = True
-
- def hoot(self,
- sprayDur,
- delay,
- sampleMethod=None,
- sampleFreq=60,
- saveDir='output',
- camera_name='cam1',
- algorithm='exg',
- confidence=0.5,
- minArea=10,
- log_fps=False):
+ for i in range(self.relay_num):
+ laneX = int(i * self.lane_width)
+ self.lane_coords[i] = laneX
+
+ # Precompute the integer lane coordinates for reuse
+ self.lane_coords_int = {k: int(v) for k, v in self.lane_coords.items()}
+
+ def hoot(self):
+ self.record_video = False # Flag to control video recording
+ self.video_writer = None
+
+ algorithm = self.config.get('System', 'algorithm')
+ log_fps = self.config.getboolean('DataCollection', 'log_fps')
+ if self.controller:
+ self.controller.update_state()
# track FPS and framecount
- frameCount = 0
- if sampleMethod is not None:
- if not os.path.exists(saveDir):
- os.makedirs(saveDir)
+ frame_count = 0
if log_fps:
fps = FPS().start()
- if algorithm == 'gog':
- weed_detector = GreenOnGreen()
+ try:
+ if algorithm == 'gog':
+ from utils.greenongreen import GreenOnGreen
+ model_path = self.config.get('GreenOnGreen', 'model_path')
+ confidence = self.config.getfloat('GreenOnGreen', 'confidence')
- else:
- weed_detector = GreenOnBrown(algorithm=algorithm)
+ weed_detector = GreenOnGreen(model_path=model_path)
+
+ else:
+ min_detection_area = self.config.getint('GreenOnBrown', 'min_detection_area')
+ invert_hue = self.config.getboolean('GreenOnBrown', 'invert_hue')
+
+ weed_detector = GreenOnBrown(algorithm=algorithm)
+
+ except (ModuleNotFoundError, IndexError, FileNotFoundError, ValueError) as e:
+ algo_error = errors.AlgorithmError(algorithm, e)
+ algo_error.handle(self)
+
+ except Exception as e:
+ algo_error = errors.AlgorithmError(algorithm, e)
+ algo_error.handle(self)
+
+ if self.show_display:
+ self.relay_vis = self.relay_controller.relay_vis
+ self.relay_vis.setup()
+ self.relay_controller.vis = True
try:
+ actuation_duration = self.config.getfloat('System', 'actuation_duration')
+ delay = self.config.getfloat('System', 'delay')
+
while True:
- delay = self.update_delay(delay)
frame = self.cam.read()
- if self.recording:
- self.record = self.recorderButton.record
- self.saveRecording = self.recorderButton.saveRecording
+ if self.focus:
+ grey = cv2.cvtColor(frame.copy(), cv2.COLOR_BGR2GRAY)
+ blurriness = fft_blur(grey, size=30)
if frame is None:
if log_fps:
fps.stop()
- print("[INFO] Stopped. Approximate FPS: {:.2f}".format(fps.fps()))
+ self.logger.info(f"[INFO] Stopped. Approximate FPS: {fps.fps():.2f}")
self.stop()
break
else:
- print("[INFO] Stopped.")
+ self.logger.info("[INFO] Frame is None. Stopped.")
self.stop()
break
- if self.record and self.writer is None:
- saveDir = os.path.join(saveDir, strftime("%Y%m%d-{}-{}".format(camera_name, algorithm)))
- if not os.path.exists(saveDir):
- os.makedirs(saveDir)
-
- self.baseName = os.path.join(saveDir, strftime("%Y%m%d-%H%M%S-{}-{}".format(camera_name, algorithm)))
- videoName = self.baseName + '.avi'
- self.logger.new_video_logfile(name=self.baseName + '.txt')
- self.writer = cv2.VideoWriter(videoName, self.fourcc, 30, (frame.shape[1], frame.shape[0]), True)
-
# retrieve the trackbar positions for thresholds
if self.show_display:
- self.exgMin = cv2.getTrackbarPos("ExG-Min", self.window_name)
- self.exgMax = cv2.getTrackbarPos("ExG-Max", self.window_name)
- self.hueMin = cv2.getTrackbarPos("Hue-Min", self.window_name)
- self.hueMax = cv2.getTrackbarPos("Hue-Max", self.window_name)
- self.saturationMin = cv2.getTrackbarPos("Sat-Min", self.window_name)
- self.saturationMax = cv2.getTrackbarPos("Sat-Max", self.window_name)
- self.brightnessMin = cv2.getTrackbarPos("Bright-Min", self.window_name)
- self.brightnessMax = cv2.getTrackbarPos("Bright-Max", self.window_name)
-
- else:
- # this leaves it open to adding dials for sensitivity. Static at the moment, but could be dynamic
- self.update(exgMin=self.exgMin, exgMax=self.exgMax) # add in update values here
+ self.exg_min = cv2.getTrackbarPos("ExG-Min", self.window_name)
+ self.exg_max = cv2.getTrackbarPos("ExG-Max", self.window_name)
+ self.hue_min = cv2.getTrackbarPos("Hue-Min", self.window_name)
+ self.hue_max = cv2.getTrackbarPos("Hue-Max", self.window_name)
+ self.saturation_min = cv2.getTrackbarPos("Sat-Min", self.window_name)
+ self.saturation_max = cv2.getTrackbarPos("Sat-Max", self.window_name)
+ self.brightness_min = cv2.getTrackbarPos("Bright-Min", self.window_name)
+ self.brightness_max = cv2.getTrackbarPos("Bright-Max", self.window_name)
# pass image, thresholds to green_on_brown function
- if algorithm == 'gog':
- cnts, boxes, weedCentres, imageOut = weed_detector.inference(frame.copy(),
- confidence=confidence,
- filter_id=63)
- else:
- cnts, boxes, weedCentres, imageOut = weed_detector.inference(frame.copy(), exgMin=self.exgMin,
- exgMax=self.exgMax,
- hueMin=self.hueMin,
- hueMax=self.hueMax,
- saturationMin=self.saturationMin,
- saturationMax=self.saturationMax,
- brightnessMin=self.brightnessMin,
- brightnessMax=self.brightnessMax,
- show_display=self.show_display,
- algorithm=algorithm, minArea=minArea)
+ if not self.disable_detection:
+ if algorithm == 'gog':
+ cnts, boxes, weed_centres, image_out = weed_detector.inference(
+ frame,
+ confidence=confidence,
+ filter_id=63
+ )
+ else:
+ cnts, boxes, weed_centres, image_out = weed_detector.inference(
+ frame,
+ exg_min=self.exg_min,
+ exg_max=self.exg_max,
+ hue_min=self.hue_min,
+ hue_max=self.hue_max,
+ saturation_min=self.saturation_min,
+ saturation_max=self.saturation_max,
+ brightness_min=self.brightness_min,
+ brightness_max=self.brightness_max,
+ show_display=self.show_display,
+ algorithm=algorithm,
+ min_detection_area=min_detection_area,
+ invert_hue=invert_hue,
+ label='WEED'
+ )
+
+ if len(weed_centres) > 0 and self.controller:
+ self.controller.weed_detect_indicator()
+
+ # loop over the weed centres
+ for centre in weed_centres:
+ if centre[1] > self.yAct:
+ actuation_time = time.time()
+ centre_x = centre[0]
+
+ for i in range(self.relay_num):
+ lane_start = self.lane_coords_int[i]
+ lane_end = lane_start + self.lane_width
+ if lane_start <= centre_x < lane_end:
+ self.relay_controller.receive(
+ relay=i,
+ delay=delay,
+ time_stamp=actuation_time,
+ duration=actuation_duration)
##### IMAGE SAMPLER #####
# record sample images if required of weeds detected. sampleFreq specifies how often
- if sampleMethod is not None:
- # only record every sampleFreq number of frames. If sampleFreq = 60, this will activate every 60th frame
- if frameCount % sampleFreq == 0:
- saveFrame = frame.copy()
-
- if sampleMethod == 'whole':
- whole_image_thread = Thread(target=whole_image_save,
- args=[saveFrame, saveDir, frameCount])
- whole_image_thread.start()
-
- elif sampleMethod == 'bbox':
- sample_thread = Thread(target=bounding_box_image_sample,
- args=[saveFrame, boxes, saveDir, frameCount])
- sample_thread.start()
-
- elif sampleMethod == 'square':
- sample_thread = Thread(target=square_image_sample,
- args=[saveFrame, weedCentres, saveDir, frameCount, 200])
- sample_thread.start()
-
+ if self.sample_images:
+ # only record every sampleFreq number of frames. If sample_frequency = 60, this will activate every 60th frame
+ if frame_count % self.sample_frequency == 0:
+ if self.sample_method == 'whole':
+ self.image_recorder.add_frame(frame=frame, frame_id=frame_count, boxes=None, centres=None)
+
+ elif self.sample_method != 'whole' and not self.disable_detection:
+ self.image_recorder.add_frame(frame=frame, frame_id=frame_count, boxes=boxes,
+ centres=weed_centres)
else:
- # if nothing/incorrect specified - sample the whole image
- whole_image_thread = Thread(target=whole_image_save,
- args=[imageOut, saveDir, frameCount])
- whole_image_thread.start()
-
-
- frameCount += 1
- # ########################
-
- # loop over the ID/weed centres from contours
- for ID, centre in enumerate(weedCentres):
- # if they are in activation region the spray them
- if centre[1] > self.yAct:
- sprayTime = time.time()
- for i in range(self.nozzleNum):
- # determine which lane needs to be activated
- if int(self.laneCoords[i]) <= centre[0] < int(self.laneCoords[i] + self.laneWidth):
- # log a spray job with the controller using the nozzle, delay, timestamp and spray duration
- # if GPS is used/speed control, delay can be updated automatically based on forward speed
- self.controller.receive(nozzle=i, delay=delay, timeStamp=sprayTime, duration=sprayDur)
+ self.image_recorder.add_frame(frame=frame, frame_id=frame_count, boxes=None, centres=None)
+
+ if self.controller:
+ self.status_indicator.image_write_indicator()
+
+ if self.status_indicator.DRIVE_FULL:
+ self.sample_images = False
+ self.image_recorder.stop()
+ self.status_indicator.error(5)
+
+ frame_count = frame_count + 1 if frame_count < 900 else 1
+
+ if log_fps and frame_count % 900 == 0:
+ fps.stop()
+ self.logger.info(f"[INFO] Approximate FPS: {fps.fps():.2f}")
+ fps = FPS().start()
# update the framerate counter
if log_fps:
fps.update()
if self.show_display:
- cv2.putText(imageOut, 'OWL-gorithm: {}'.format(algorithm), (20, 35), cv2.FONT_HERSHEY_SIMPLEX, 0.75,
+ if self.disable_detection:
+ image_out = frame.copy()
+
+ if self.record_video:
+ if self.video_writer is None:
+ # Initialize video writer
+ fourcc = cv2.VideoWriter_fourcc(*'mp4v')
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ video_filename = f"owl_recording_{timestamp}.mp4"
+ self.video_writer = cv2.VideoWriter(video_filename, fourcc, 30.0,
+ (frame.shape[1], frame.shape[0]))
+
+ # Write the frame with detections
+ self.video_writer.write(image_out)
+
+ cv2.putText(image_out, f'OWL-gorithm: {algorithm}', (20, 35), cv2.FONT_HERSHEY_SIMPLEX, 0.75,
(80, 80, 255), 1)
- cv2.putText(imageOut, 'Press "S" to save thresholds to file.'.format(algorithm),
- (20, int(imageOut.shape[1 ] *0.72)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (80, 80, 255), 1)
- cv2.imshow("Detection Output", imutils.resize(imageOut, width=600))
+ cv2.putText(image_out, f'Press "S" to save {algorithm} thresholds to file.',
+ (20, int(image_out.shape[1 ] *0.72)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (80, 80, 255), 1)
+ if self.focus:
+ cv2.putText(image_out, f'Blurriness: {blurriness:.2f}', (20, 70), cv2.FONT_HERSHEY_SIMPLEX, 1,
+ (80, 80, 255), 1)
- if self.record and not self.saveRecording:
- self.writer.write(frame)
-
- if self.saveRecording and not self.record:
- self.writer.release()
- self.controller.solenoid.beep(duration=0.1)
- self.recorderButton.saveRecording = False
- if log_fps:
- fps.stop()
- self.logger.log_line_video(
- "[INFO] Approximate FPS: {:.2f}".format(fps.fps()), verbose=True)
- fps = FPS().start()
-
- self.writer = None
- self.logger.log_line_video("[INFO] {} stopped.".format(self.baseName), verbose=True)
+ cv2.imshow("Detection Output", imutils.resize(image_out, width=600))
k = cv2.waitKey(1) & 0xFF
if k == ord('s'):
self.save_parameters()
- self.logger.log_line("[INFO] Parameters saved.", verbose=True)
+ self.logger.info("[INFO] Parameters saved.")
+
+ elif k == ord('r'):
+ # Toggle video recording
+ self.record_video = not self.record_video
+ if self.record_video:
+ self.logger.info("[INFO] Started video recording.")
+ else:
+ if self.video_writer:
+ self.video_writer.release()
+ self.video_writer = None
+ self.logger.info("[INFO] Stopped video recording.")
- if k == 27:
+ elif k == 27:
if log_fps:
fps.stop()
- self.logger.log_line_video(
- "[INFO] Approximate FPS: {:.2f}".format(fps.fps()),
- verbose=True)
- self.controller.nozzle_vis.close()
- self.logger.log_line("[INFO] Stopped.", verbose=True)
+ self.logger.info(f"[INFO] Approximate FPS: {fps.fps():.2f}")
+ if self.show_display:
+ self.relay_controller.relay_vis.close()
+
+ self.logger.info("[INFO] Stopped.")
self.stop()
break
except KeyboardInterrupt:
if log_fps:
fps.stop()
- self.logger.log_line(
- "[INFO] Approximate FPS: {:.2f}".format(fps.fps()),
- verbose=True)
- self.controller.nozzle_vis.close()
- self.logger.log_line("[INFO] Stopped.", verbose=True)
+ self.logger.info(f"[INFO] Approximate FPS: {fps.fps():.2f}")
+ if self.show_display:
+ self.relay_controller.relay_vis.close()
+ self.logger.info("[INFO] Stopped.")
self.stop()
except Exception as e:
- print(e)
- self.controller.solenoid.beep(duration=0.5, repeats=5)
- self.logger.log_line("[CRITICAL ERROR] STOPPED: {}".format(e))
+ self.logger.error(f"[CRITICAL ERROR] STOPPED: {e}", exc_info=True)
+ self.stop()
- # still in development
- def update_software(self):
- USBDir, USBConnected = check_for_usb()
- if USBConnected:
- files = os.listdir(USBDir)
- workingDir = '/home/pi'
+ def stop(self):
+ """Gracefully shut down all OWL components."""
- # move old version to version control directory first
- oldVersionDir = strftime(workingDir + "/%Y%m%d-%H%M%S_update")
- os.mkdir(oldVersionDir)
+ def safe_stop(component, name, fallback_to_terminate=True):
+ """
+ Attempt to gracefully stop a component, with an option to terminate if stopping fails.
+ """
+ try:
+ if hasattr(component, 'stop'):
+ component.stop()
+ self.logger.info(f"Stopped {name}")
+ except Exception as e:
+ self.logger.warning(f"Graceful stop failed for {name}: {e}")
+ if fallback_to_terminate and hasattr(component, 'terminate'):
+ try:
+ component.terminate()
+ self.logger.info(f"Forcefully terminated {name}")
+ except Exception as terminate_error:
+ self.logger.error(f"Failed to terminate {name}: {terminate_error}")
- currentDir = '/home/pi/owl'
- shutil.move(currentDir, oldVersionDir)
+ try:
+ # Stop controller processes
+ if hasattr(self, 'controller') and self.controller:
+ safe_stop(self.controller, 'controller', fallback_to_terminate=False)
+ if hasattr(self, 'controller_process') and self.controller_process.is_alive():
+ self.controller_process.terminate()
+ self.controller_process.join(timeout=0.5)
+ self.logger.info("Controller process terminated")
+
+ # Stop image recorder
+ if hasattr(self, 'image_recorder') and self.image_recorder:
+ safe_stop(self.image_recorder, 'image recorder')
+
+ # Stop status indicator
+ if hasattr(self, 'status_indicator') and self.status_indicator:
+ safe_stop(self.status_indicator, 'status indicator', fallback_to_terminate=False)
+
+ # Stop relay controller
+ if hasattr(self, 'relay_controller') and self.relay_controller:
+ safe_stop(self.relay_controller, 'relay controller', fallback_to_terminate=False)
+ try:
+ self.relay_controller.relay.all_off() # Ensure all relays are off
+ except Exception as e:
+ self.logger.warning(f"Failed to turn off relays: {e}")
+
+ # Stop camera
+ if hasattr(self, 'cam') and self.cam:
+ safe_stop(self.cam, 'camera', fallback_to_terminate=False)
- # move new directory to working directory
- for item in files:
- if 'owl' in item:
- shutil.move()
+ except Exception as e:
+ self.logger.error(f"Critical error during shutdown: {e}", exc_info=True)
+ finally:
+ try:
+ LogManager().stop() # Ensure logger shuts down properly
+ self.logger.info("OWL shutdown complete")
+ except Exception as log_error:
+ print(f"Failed to stop LogManager: {log_error}", file=sys.stderr)
+ sys.exit(0)
- def stop(self):
- self.controller.running = False
- self.controller.solenoid.all_off()
- self.controller.solenoid.beep(duration=0.1)
- self.controller.solenoid.beep(duration=0.1)
- self.cam.stop()
- if self.record:
- self.writer.release()
- self.recorderButton.running = False
+ def save_parameters(self):
+ timestamp = datetime.now().strftime('%Y%m%d-%H%M%S')
+ new_config_filename = f"{timestamp}_{self._config_path.name}"
+ new_config_path = self._config_path.parent / new_config_filename
+
+ # Update the 'GreenOnBrown' section with current attribute values
+ if 'GreenOnBrown' not in self.config.sections():
+ self.config.add_section('GreenOnBrown')
+
+ self.config.set('GreenOnBrown', 'exg_min', str(self.exg_min))
+ self.config.set('GreenOnBrown', 'exg_max', str(self.exg_max))
+ self.config.set('GreenOnBrown', 'hue_min', str(self.hue_min))
+ self.config.set('GreenOnBrown', 'hue_max', str(self.hue_max))
+ self.config.set('GreenOnBrown', 'saturation_min', str(self.saturation_min))
+ self.config.set('GreenOnBrown', 'saturation_max', str(self.saturation_max))
+ self.config.set('GreenOnBrown', 'brightness_min', str(self.brightness_min))
+ self.config.set('GreenOnBrown', 'brightness_max', str(self.brightness_max))
+
+ # Write the updated configuration to the new file with a timestamped filename
+ with open(new_config_path, 'w') as configfile:
+ self.config.write(configfile)
+
+ self.logger.info(f"[INFO] Configuration saved to {new_config_path}")
+
+ def setup_media_source(self, input_file_or_directory):
+ """
+ Configure and initialize the appropriate media source (camera or media file/directory).
+
+ Args:
+ input_file_or_directory: Optional path from CLI args to image/video source
+
+ Returns:
+ VideoStream or FrameReader: Initialized media source
+
+ Raises:
+ FileNotFoundError: If specified media path does not exist
+ InvalidMediaError: If specified file is not a valid image/video format
+ RuntimeError: If media source initialization fails
+ """
+ # Determine input source with CLI taking precedence over config
+ if input_file_or_directory:
+ if len(self.config.get('System', 'input_file_or_directory')) > 0:
+ self.logger.warning('[WARNING] Input sources provided in both CLI and config file. Using CLI argument.')
+ self.input_file_or_directory = input_file_or_directory
+ else:
+ self.input_file_or_directory = self.config.get('System', 'input_file_or_directory').strip('"\'')
- if self.show_display:
- cv2.destroyAllWindows()
+ if self.input_file_or_directory:
+ path = Path(self.input_file_or_directory)
- sys.exit()
+ if not path.exists():
+ raise errors.MediaPathError(path=path, message="Specified input path does not exist")
- def update(self, exgMin=30, exgMax=180):
- self.exgMin = exgMin
- self.exgMax = exgMax
+ if path.is_file():
+ valid_extensions = {
+ '.jpg', '.jpeg', '.png', '.bmp', # Images
+ '.mp4', '.avi', '.mov', '.mkv' # Videos
+ }
+ if path.suffix.lower() not in valid_extensions:
+ raise errors.InvalidMediaError(path=path, valid_formats=valid_extensions)
- def update_delay(self, delay=0):
- # if GPS added, could use it here to return a delay variable based on speed.
- return delay
+ try:
+ media_source = FrameReader(path=self.input_file_or_directory,
+ resolution=self.resolution,
+ loop_time=self.image_loop_time)
- def save_parameters(self):
- self.thresholdDict['exgMin'] = cv2.getTrackbarPos("ExG-Min", self.window_name)
- self.thresholdDict['exgMax'] = cv2.getTrackbarPos("ExG-Max", self.window_name)
- self.thresholdDict['hueMin'] = cv2.getTrackbarPos("Hue-Min", self.window_name)
- self.thresholdDict['hueMax'] = cv2.getTrackbarPos("Hue-Max", self.window_name)
- self.thresholdDict['saturationMin'] = cv2.getTrackbarPos("Sat-Min", self.window_name)
- self.thresholdDict['saturationMax'] = cv2.getTrackbarPos("Sat-Max", self.window_name)
- self.thresholdDict['brightnessMin'] = cv2.getTrackbarPos("Bright-Min", self.window_name)
- self.thresholdDict['brightnessMax'] = cv2.getTrackbarPos("Bright-Max", self.window_name)
-
- datetime.now(timezone.utc).strftime("%Y%m%d")
- json_name = datetime.now(timezone.utc).strftime("%Y%m%d%H%M") + '-owl-parameters.json'
- with open(json_name, 'w') as f:
- json.dump(self.thresholdDict, f)
-
-def check_for_usb():
- try:
- nanoMediaFolder = 'ls /media/pi'
- proc = subprocess.Popen(nanoMediaFolder, shell=True, preexec_fn=os.setsid, stdout=subprocess.PIPE)
- usbName = proc.stdout.readline().rstrip().decode('utf-8')
-
- if len(usbName) > 0:
- print('[INFO] Saving to {} usb'.format(usbName))
- saveDir = '/media/pi/{}/'.format(usbName)
- return saveDir, True
+ self.frame_width, self.frame_height = media_source.resolution
+ self.logger.info(f'[INFO] Using {media_source.input_type} from {self.input_file_or_directory}...')
+ return media_source
- else:
- print('[INFO] No USB connected. Saving to videos')
- saveDir = '/home/pi/owl/videos'
- return saveDir, False
+ except Exception as e:
+ raise errors.MediaInitError(path=path, original_error=str(e)) from e
+
+ # Set up camera if no file input specified
+ try:
+ media_source = VideoStream(resolution=self.resolution,
+ exp_compensation=self.exp_compensation)
+ media_source.start()
+
+ self.frame_width = media_source.frame_width
+ self.frame_height = media_source.frame_height
- except AttributeError:
- print('[INFO] Windows computer detected...')
- saveDir = '/videos/'
- return saveDir, False
+ return media_source
+
+ except IndexError as e:
+ self.logger.error("Camera index not found", exc_info=True)
+ self.status_indicator.error(2)
+ self.stop()
+ raise errors.CameraNotFoundError(error_type="Camera Not Found", original_error=str(e))
+
+ except ModuleNotFoundError as e:
+ self.logger.error(e, exc_info=True)
+ module_name = str(e).split("'")[-2]
+ self.status_indicator.error(1)
+ self.stop()
+ raise errors.DependencyError(missing_module=module_name, error_msg=str(e)) from None
+
+ except Exception as e:
+ error_msg = f"[CRITICAL ERROR] Failed to initialize camera: {str(e)}"
+ self.logger.error(error_msg)
+ self.status_indicator.error(1)
+ self.stop()
+ raise errors.CameraInitError(str(e)) from e
+
+ def _log_system_info(self):
+ """Log system information on startup"""
+ self.logger.info(f"Starting OWL version {VERSION}")
+
+ try:
+ sys_info = SystemInfo.get_os_info()
+ self.logger.info(
+ f"System Information: OS: {sys_info['system']} {sys_info['release']}, "
+ f"Machine: {sys_info['machine']}"
+ )
+ except Exception as e:
+ self.logger.warning(f"Failed to retrieve OS information: {e}")
+
+ try:
+ python_info = SystemInfo.get_python_info()
+ self.logger.info(
+ f"Python Version: {python_info['version']}, "
+ f"Implementation: {python_info['implementation']}, "
+ f"Compiler: {python_info['compiler']}"
+ )
+ except Exception as e:
+ self.logger.warning(f"Failed to retrieve Python information: {e}")
+
+ try:
+ rpi_info = SystemInfo.get_rpi_info()
+ if rpi_info:
+ self.logger.info(f"Hardware: {rpi_info}")
+ else:
+ self.logger.info("Raspberry Pi hardware info not available.")
+ except Exception as e:
+ self.logger.warning(f"Failed to retrieve Raspberry Pi information: {e}")
+
+ try:
+ git_info = SystemInfo.get_git_info()
+ if git_info:
+ self.logger.info(f"Git: branch={git_info['branch']}, commit={git_info['commit']}")
+ else:
+ self.logger.info("Git information not available.")
+ except Exception as e:
+ self.logger.warning(f"Failed to retrieve Git information: {e}")
# business end of things
if __name__ == "__main__":
# these command line arguments enable people to operate/change some settings from the command line instead of
- # opening up a the OWL code each time.
+ # opening up the OWL code each time.
ap = argparse.ArgumentParser()
- ap.add_argument('--video-file', type=str, default=None, help='use video file instead')
ap.add_argument('--show-display', action='store_true', default=False, help='show display windows')
- ap.add_argument('--recording', action='store_true', default=False, help='record video')
- ap.add_argument('--algorithm', type=str, default='exhsv', choices=['exg', 'nexg', 'exgr', 'maxg', 'exhsv', 'hsv', 'gog'])
- ap.add_argument('--conf', type=float, default=0.5, choices=np.arange(0.01, 0.99, 0.01), metavar="2 s.f. Float between 0.01 and 1.00",
- help='set the confidence value for a "green-on-green" algorithm between 0.01 and 1.00. Must be a two-digit float.')
- ap.add_argument('--framerate', type=int, default=40, choices=range(10, 121), metavar="[10-120]",
- help='set camera framerate between 10 and 120 FPS. Framerate will depend on sensor mode, though'
- ' setting framerate takes precedence over sensor_mode, For example sensor_mode=0 and framerate=120'
- ' will reset the sensor_mode to 3.')
- ap.add_argument('--exp-mode', type=str, default='beach', choices=['off', 'auto', 'nightpreview', 'backlight',
- 'spotlight', 'sports', 'snow', 'beach',
- 'verylong', 'fixedfps', 'antishake',
- 'fireworks'],
- help='set exposure mode of camera')
- ap.add_argument('--awb-mode', type=str, default='auto', choices=['off', 'auto', 'sunlight', 'cloudy', 'shade',
- 'tungsten', 'fluorescent', 'incandescent',
- 'flash', 'horizon'],
- help='set the auto white balance mode of the camera')
- ap.add_argument('--sensor-mode', type=int, default=0, choices=[0, 1, 2, 3], metavar="[0 to 3]",
- help='set the sensor mode for the camera between 0 and 3. '
- 'Check Raspberry Pi camera documentation for specifics of each mode')
- ap.add_argument('--exp-compensation', type=int, default=-6, choices=range(-24, 24), metavar="[-24 to 24]",
- help='set the exposure compensation (EV) for the camera between -24 and 24. '
- 'Raspberry Pi cameras seem to overexpose images preferentially.')
+ ap.add_argument('--focus', action='store_true', default=False, help='add FFT blur to output frame')
+ ap.add_argument('--input', type=str, default=None, help='path to image directory, single image or video file')
+
args = ap.parse_args()
- owl = Owl(videoFile=args.video_file,
- show_display=args.show_display,
- recording=args.recording,
- exgMin=25,
- exgMax=200,
- hueMin=39,
- hueMax=83,
- saturationMin=50,
- saturationMax=220,
- brightnessMin=60,
- brightnessMax=190,
- resolution=(416, 320),
- nozzleNum=4,
- framerate=args.framerate,
- exp_mode=args.exp_mode,
- exp_compensation=args.exp_compensation,
- awb_mode=args.awb_mode,
- sensor_mode=args.sensor_mode,
- parameters_json=None
- )
+ # this is where you can change the config file default
+ owl = Owl(
+ config_file='config/DAY_SENSITIVITY_2.ini',
+ show_display=args.show_display,
+ focus=args.focus,
+ input_file_or_directory=args.input
+ )
# start the targeting!
- owl.hoot(sprayDur=0.15,
- delay=0,
- sampleMethod=None, # choose from 'bbox' | 'square' | 'whole'. If sampleMethod=None, it won't sample anything
- sampleFreq=30, # select how often to sample - number of frames to skip.
- saveDir='images/bbox2',
- algorithm=args.algorithm,
- camera_name='hsv',
- minArea=10,
- confidence=args.conf
- )
+ owl.hoot()
diff --git a/owl_boot.sh b/owl_boot.sh
index c4e7f9d..9ebf6b9 100644
--- a/owl_boot.sh
+++ b/owl_boot.sh
@@ -1,7 +1,14 @@
#!/bin/bash
-source /home/pi/.bashrc
-workon owl
-lxterminal
-cd /home/pi/owl
-./greenonbrown.py
\ No newline at end of file
+# automatically determine the home directory, to avoid issues with username
+source $HOME/.bashrc
+
+# activate the 'owl' virtual environment
+source $HOME/.virtualenvs/owl/bin/activate
+
+# change directory to the owl folder
+cd $HOME/owl
+
+# run owl.py in the background and save the log output
+LOG_DATE=$(date -u +"%Y-%m-%dT%H-%M-%SZ")
+./owl.py > $HOME/owl/logs/owl_$LOG_DATE.log 2>&1 &
diff --git a/owl_boot_wrapper.sh b/owl_boot_wrapper.sh
new file mode 100644
index 0000000..98c77af
--- /dev/null
+++ b/owl_boot_wrapper.sh
@@ -0,0 +1,19 @@
+#!/bin/bash
+
+# This script will find the user's home directory, making OWL software more portable.
+for dir in /home/*; do
+ if [ -d "$dir" ]; then
+ username=$(basename "$dir")
+ if [ "$username" != "root" ]; then
+ HOME_DIR="$dir"
+ break
+ fi
+ fi
+done
+
+if [ -z "$HOME_DIR" ]; then
+ echo "No suitable user directory found."
+ exit 1
+fi
+
+sudo -u "$username" -H /usr/local/bin/owl_boot.sh
\ No newline at end of file
diff --git a/owl_setup.sh b/owl_setup.sh
new file mode 100644
index 0000000..cb7dadc
--- /dev/null
+++ b/owl_setup.sh
@@ -0,0 +1,212 @@
+#!/bin/bash
+
+# Define colors for status messages
+RED='\033[0;31m' # Red for ERROR messages
+ORANGE='\033[0;33m' # Orange for warnings
+GREEN='\033[0;32m' # Green for INFO and success messages
+NC='\033[0m' # No color (reset)
+TICK="${GREEN}[OK]${NC}"
+CROSS="${RED}[FAIL]${NC}"
+SCRIPT_DIR=$(dirname "$(realpath "$0")")
+CURRENT_USER=${SUDO_USER:-$(whoami)}
+
+if [ "$SUDO_USER" ]; then
+ echo -e "${RED}[ERROR] This script should not be run with sudo. Please run as normal user.${NC}"
+ exit 1
+fi
+
+# Initialize status tracking variables
+STATUS_UPGRADE=""
+STATUS_CAMERA=""
+STATUS_CAMERA_TEST=""
+STATUS_FULL_UPGRADE=""
+STATUS_VENV=""
+STATUS_OPENCV=""
+STATUS_OWL_DEPS=""
+STATUS_BOOT_SCRIPTS=""
+
+ERROR_UPGRADE=""
+ERROR_CAMERA=""
+ERROR_CAMERA_TEST=""
+ERROR_FULL_UPGRADE=""
+ERROR_VENV=""
+ERROR_OPENCV=""
+ERROR_OWL_DEPS=""
+ERROR_BOOT_SCRIPTS=""
+
+if [ "$CURRENT_USER" != "owl" ]; then
+ echo -e "${ORANGE}[WARNING] Current user '$CURRENT_USER' differs from expected 'owl'. Some settings may not work correctly.${NC}"
+fi
+
+# Function to check the exit status of the last executed command
+check_status() {
+ if [ $? -ne 0 ]; then
+ echo -e "${CROSS} $1 failed."
+ eval "STATUS_$2='${CROSS}'"
+ eval "ERROR_$2='$1 failed'"
+ return 1
+ else
+ echo -e "${TICK} $1 completed successfully."
+ eval "STATUS_$2='${TICK}'"
+ fi
+}
+
+# Source bashrc to ensure virtualenv commands are available
+reload_bashrc() {
+ if [ -f ~/.bashrc ]; then
+ source ~/.bashrc
+ sleep 2
+ fi
+}
+
+# Function to check if the camera is detected
+check_camera_connection() {
+ echo -e "${GREEN}[INFO] Checking for connected Raspberry Pi camera...${NC}"
+ while true; do
+ if rpicam-hello --list-cameras 2>&1 | grep -q "No cameras available"; then
+ echo -e "${RED}[ERROR] No camera detected!${NC}"
+ read -p "Please connect a Raspberry Pi camera and press Enter to retry..." temp
+ else
+ echo -e "${GREEN}[INFO] Camera detected successfully.${NC}"
+ STATUS_CAMERA="${TICK}"
+ return 0
+ fi
+ done
+}
+
+# Step 1: Perform a normal system update and upgrade
+echo -e "${GREEN}[INFO] Updating and upgrading the system...${NC}"
+sudo apt update
+sudo apt full-upgrade -y
+check_status "System upgrade" "UPGRADE"
+
+# Step 2: Ensure a camera is connected before proceeding
+check_camera_connection
+
+# Step 3: Test camera functionality
+echo -e "${GREEN}[INFO] Testing camera functionality...${NC}"
+rpicam-hello > /dev/null 2>&1
+if [ $? -ne 0 ]; then
+ echo -e "${RED}[WARNING] Camera test failed. Running full system upgrade to resolve potential issues...${NC}"
+ sudo apt full-upgrade -y
+ check_status "Full system upgrade" "FULL_UPGRADE"
+
+ echo -e "${GREEN}[INFO] Retesting camera after full upgrade...${NC}"
+ rpicam-hello > /dev/null 2>&1
+ if [ $? -ne 0 ]; then
+ echo -e "${RED}[CRITICAL ERROR] Camera still not working after full upgrade. Please log an issue: https://github.com/geezacoleman/OpenWeedLocator/issues${NC}"
+ STATUS_CAMERA_TEST="${CROSS}"
+ ERROR_CAMERA_TEST="No camera detected"
+ else
+ echo -e "${GREEN}[INFO] Camera test passed after full upgrade.${NC}"
+ STATUS_CAMERA_TEST="${TICK}"
+ fi
+else
+ echo -e "${GREEN}[INFO] Camera is working correctly.${NC}"
+ STATUS_CAMERA_TEST="${TICK}"
+fi
+
+# Step 4: Free up space
+echo -e "${GREEN}[INFO] Freeing up space by removing unnecessary packages...${NC}"
+sudo apt-get purge -y wolfram-engine libreoffice*
+sudo apt-get clean
+check_status "Cleaning up" "CLEANUP"
+
+# Step 5: Set up the virtual environment
+echo -e "${GREEN}[INFO] Setting up the virtual environment...${NC}"
+
+# Add config to bashrc if not already present
+if ! grep -q "virtualenv and virtualenvwrapper" /home/$CURRENT_USER/.bashrc; then
+ cat >> /home/$CURRENT_USER/.bashrc << EOF
+# virtualenv and virtualenvwrapper
+export VIRTUALENVWRAPPER_PYTHON=/usr/bin/python3
+export WORKON_HOME=\$HOME/.virtualenvs
+source /usr/share/virtualenvwrapper/virtualenvwrapper.sh
+EOF
+fi
+
+reload_bashrc
+sudo apt-get install -y python3-virtualenv python3-virtualenvwrapper
+check_status "Installing virtualenv packages" "VENV"
+
+reload_bashrc
+source /usr/share/virtualenvwrapper/virtualenvwrapper.sh
+check_status "Virtualenv configuration" "VENV"
+
+reload_bashrc
+# Step 6: Create and configure the virtual environment
+echo -e "${GREEN}[INFO] Creating the 'owl' virtual environment...${NC}"
+mkvirtualenv --system-site-packages -p python3 owl
+check_status "Creating virtual environment 'owl'" "VENV"
+
+sleep 1s
+
+# Step 7: Install OpenCV in the virtual environment
+echo -e "${GREEN}[INFO] Installing OpenCV in the 'owl' virtual environment...${NC}"
+source $HOME/.virtualenvs/owl/bin/activate
+sleep 1s
+pip3 install opencv-contrib-python
+check_status "Installing OpenCV" "OPENCV"
+
+sleep 1s
+
+# Step 8: Install OWL dependencies
+echo -e "${GREEN}[INFO] Installing the OWL Python dependencies...${NC}"
+cd "$SCRIPT_DIR"
+pip install -r requirements.txt
+check_status "Installing dependencies from requirements.txt" "OWL_DEPS"
+
+# Step 9: Make scripts executable and set up boot configuration
+echo -e "${GREEN}[INFO] Making scripts executable...${NC}"
+chmod a+x owl.py
+check_status "Making owl.py executable" "BOOT_SCRIPTS"
+
+chmod a+x owl_boot.sh
+chmod a+x owl_boot_wrapper.sh
+check_status "Making boot scripts executable" "BOOT_SCRIPTS"
+
+echo -e "${GREEN}[INFO] Moving boot scripts...${NC}"
+sudo mv owl_boot.sh /usr/local/bin/
+sudo mv owl_boot_wrapper.sh /usr/local/bin/
+check_status "Moving boot scripts" "BOOT_SCRIPTS"
+
+# Add boot script to cron
+echo -e "${GREEN}[INFO] Adding boot script to cron...${NC}"
+(crontab -l 2>/dev/null; echo "@reboot /usr/local/bin/owl_boot_wrapper.sh > /home/launch.log 2>&1") | sudo crontab -
+check_status "Adding boot script to cron" "BOOT_SCRIPTS"
+
+# set desktop background - check for wayland or X11
+echo -e "${GREEN}[INFO] Setting desktop background...${NC}"
+pcmanfm --set-wallpaper $SCRIPT_DIR/images/owl-background.png
+check_status "Setting desktop background" "BOOT_SCRIPTS"
+
+# Final Summary
+echo -e "\n${GREEN}[INFO] Installation Summary:${NC}"
+echo -e "$STATUS_UPGRADE System Upgrade"
+echo -e "$STATUS_CAMERA Camera Detected"
+echo -e "$STATUS_CAMERA_TEST Camera Test"
+
+if [[ -n "$STATUS_FULL_UPGRADE" ]]; then
+ echo -e "$STATUS_FULL_UPGRADE Full System Upgrade"
+fi
+
+echo -e "$STATUS_VENV Virtual Environment Created"
+echo -e "$STATUS_OPENCV OpenCV Installed"
+echo -e "$STATUS_OWL_DEPS OWL Dependencies Installed"
+echo -e "$STATUS_BOOT_SCRIPTS Boot Scripts Moved"
+
+# Step 10: Start OWL focusing
+read -p "Start OWL focusing? (y/n): " choice
+case "$choice" in
+ y|Y ) echo -e "${GREEN}[INFO] Starting focusing...${NC}"; ./owl.py --focus;;
+ n|N ) echo -e "${GREEN}[INFO] Focusing skipped. Run './owl.py --focus' to focus the OWL later.${NC}";;
+ * ) echo -e "${RED}[ERROR] Invalid input. Please enter y or n.${NC}";;
+esac
+
+# Step 11: Launch OWL
+read -p "Launch OWL software? (y/n): " choice
+case "$choice" in
+ y|Y ) echo -e "${GREEN}[INFO] Launching OWL...${NC}"; ./owl.py --show-display;;
+ n|N ) echo -e "${GREEN}[INFO] Skipped. Run './owl.py --show-display' to launch OWL later.${NC}";;
+ * ) echo -e "${RED}[ERROR] Invalid input. Please enter y or n.${NC}";;
+esac
diff --git a/relay_control.py b/relay_control.py
deleted file mode 100644
index 19aebd5..0000000
--- a/relay_control.py
+++ /dev/null
@@ -1,188 +0,0 @@
-from logger import Logger
-from threading import Thread, Condition
-from utils.cli_vis import NozzleVis
-import collections
-import time
-import os
-
-import platform
-# check if the system is being tested on a Windows or Linux x86 64 bit machine
-if platform.system() == "Windows":
- testing = True
-else:
- if '64' in platform.machine():
- testing = True
- else:
- from gpiozero import Buzzer, OutputDevice
- testing = False
-
-# two test classes to run the analysis on a desktop computer if a "win32" platform is detected
-class TestRelay:
- def __init__(self, relayNumber, verbose=False):
- self.relayNumber = relayNumber
- self.verbose = verbose
-
- def on(self):
- if self.verbose:
- print("[TEST] Relay {} ON".format(self.relayNumber))
-
- def off(self):
- if self.verbose:
- print("[TEST] Relay {} OFF".format(self.relayNumber))
-
-class TestBuzzer:
- def beep(self, on_time: int, off_time: int, n=1, verbose=False):
- for i in range(n):
- if verbose:
- print('BEEP')
-
-# control class for the relay board
-class RelayControl:
- def __init__(self, solenoidDict):
- self.testing = True if testing else False
- self.solenoidDict = solenoidDict
- self.on = False
-
- if not self.testing:
- self.buzzer = Buzzer(pin='BOARD7')
- for nozzle, boardPin in self.solenoidDict.items():
- self.solenoidDict[nozzle] = OutputDevice(pin='BOARD{}'.format(boardPin))
-
- else:
- self.buzzer = TestBuzzer()
- for nozzle, boardPin in self.solenoidDict.items():
- self.solenoidDict[nozzle] = TestRelay(boardPin)
-
-
- def relay_on(self, solenoidNumber, verbose=True):
- relay = self.solenoidDict[solenoidNumber]
- relay.on()
-
- if verbose:
- print("Solenoid {} ON".format(solenoidNumber))
-
- def relay_off(self, solenoidNumber, verbose=True):
- relay = self.solenoidDict[solenoidNumber]
- relay.off()
-
- if verbose:
- print("Solenoid {} OFF".format(solenoidNumber))
-
- def beep(self, duration=0.2, repeats=2):
- self.buzzer.beep(on_time=duration, off_time=(duration / 2), n=repeats)
-
- def all_on(self):
- for nozzle in self.solenoidDict.keys():
- self.relay_on(nozzle)
-
- def all_off(self):
- for nozzle in self.solenoidDict.keys():
- self.relay_off(nozzle)
-
- def remove(self, solenoidNumber):
- self.solenoidDict.pop(solenoidNumber, None)
-
- def clear(self):
- self.solenoidDict = {}
-
- def stop(self):
- self.clear()
- self.all_off()
-
-# this class does the hard work of receiving detection 'jobs' and queuing them to be actuated. It only turns a nozzle on
-# if the sprayDur has not elapsed or if the nozzle isn't already on.
-class Controller:
- def __init__(self, nozzleDict, vis=False):
- self.nozzleDict = nozzleDict
- self.vis = vis
- # instantiate relay control with supplied nozzle dictionary to map to correct board pins
- self.solenoid = RelayControl(self.nozzleDict)
- self.nozzleQueueDict = {}
- self.nozzleconditionDict = {}
-
- # start the logger and log file using absolute path of python file
- self.saveDir = os.path.join(os.path.dirname(__file__), 'logs')
- self.logger = Logger(name="weed_log.txt", saveDir=self.saveDir)
-
- # create a job queue and Condition() for each nozzle
- print("[INFO] Setting up nozzles...")
- self.nozzle_vis = NozzleVis(relays=len(self.nozzleDict.keys()))
- for nozzle in range(0, len(self.nozzleDict)):
- self.nozzleQueueDict[nozzle] = collections.deque(maxlen=5)
- self.nozzleconditionDict[nozzle] = Condition()
-
- # create the consumer threads, setDaemon and start the threads.
- nozzleThread = Thread(target=self.consumer, args=[nozzle])
- nozzleThread.setDaemon(True)
- nozzleThread.start()
-
- time.sleep(1)
- print("[INFO] Nozzle setup complete. Initiating camera...")
- self.solenoid.beep(duration=0.5)
-
- def receive(self, nozzle, timeStamp, location=0, delay=0, duration=1):
- """
- this method adds a new spray job to specified nozzle queue. GPS location data etc to be added. Time stamped
- records the true time of weed detection from main thread, which is compared to time of nozzle activation for accurate
- on durations. There will be a minimum on duration of this processing speed ~ 0.3s. Will default to 0 though.
- :param nozzle: nozzle number (zero based)
- :param timeStamp: this is the time of detection
- :param location: GPS functionality to be added here
- :param delay: on delay to be added in the future
- :param duration: duration of spray
- """
- inputQMessage = [nozzle, timeStamp, delay, duration]
- inputQ = self.nozzleQueueDict[nozzle]
- inputCondition = self.nozzleconditionDict[nozzle]
- # notifies the consumer thread when something has been added to the queue
- with inputCondition:
- inputQ.append(inputQMessage)
- inputCondition.notify()
-
- line = "nozzle: {} | time: {} | location {} | delay: {} | duration: {}".format(nozzle, timeStamp, location, delay, duration)
- self.logger.log_line(line, verbose=False)
-
- def consumer(self, nozzle):
- """
- Takes only one parameter - nozzle, which enables the selection of the deque, condition from the dictionaries.
- The consumer method is threaded for each nozzle and will wait until it is notified that a new job has been added
- from the receive method. It will then compare the time of detection with time of spraying to activate that nozzle
- for requried length of time.
- :param nozzle: nozzle vlaue
- """
- self.running = True
- inputCondition = self.nozzleconditionDict[nozzle]
- inputCondition.acquire()
- nozzleOn = False
- nozzleQueue = self.nozzleQueueDict[nozzle]
- while self.running:
- while nozzleQueue:
- sprayJob = nozzleQueue.popleft()
- inputCondition.release()
- # check to make sure time is positive
- onDur = 0 if (sprayJob[3] - (time.time() - sprayJob[1])) <= 0 else (sprayJob[3] - (time.time() - sprayJob[1]))
-
- if not nozzleOn:
- time.sleep(sprayJob[2]) # add in the delay variable
- self.solenoid.relay_on(nozzle, verbose=False)
- if self.vis:
- self.nozzle_vis.update(relay=nozzle, status=True)
- nozzleOn = True
- try:
- time.sleep(onDur)
- self.logger.log_line(
- '[INFO] onDur {} for nozzle {} received.'.format(onDur, nozzle))
-
- except ValueError:
- time.sleep(0)
- self.logger.log_line(
- '[ERROR] negative onDur {} for nozzle {} received. Turning on for 0 seconds.'.format(onDur,
- nozzle))
- inputCondition.acquire()
- if len(nozzleQueue) == 0:
- self.solenoid.relay_off(nozzle, verbose=False)
- if self.vis:
- self.nozzle_vis.update(relay=nozzle, status=False)
- nozzleOn = False
-
- inputCondition.wait()
diff --git a/requirements.txt b/requirements.txt
index 45e6a56..58f2124 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,15 +1,9 @@
## OWL requirements file ##
-#
-numpy~=1.21.2
-imutils~=0.5.4
+# OpenCV should be installed separately
+numpy
+imutils
gpiozero
-pandas~=1.3.2
-glob2
+pandas
RPi.GPIO
-picamera
-
-piexif~=1.1.3
-folium~=0.14.0
-opencv-python~=4.5.5.64
-pycoral~=2.0.0
-tqdm~=4.64.1
\ No newline at end of file
+tqdm
+blessed
diff --git a/startup/initiating_sequence.mp3 b/startup/initiating_sequence.mp3
deleted file mode 100644
index 79b7904..0000000
Binary files a/startup/initiating_sequence.mp3 and /dev/null differ
diff --git a/startup/initiation_complete.mp3 b/startup/initiation_complete.mp3
deleted file mode 100644
index f744291..0000000
Binary files a/startup/initiation_complete.mp3 and /dev/null differ
diff --git a/update_owl.sh b/update_owl.sh
new file mode 100644
index 0000000..2d537d5
--- /dev/null
+++ b/update_owl.sh
@@ -0,0 +1,28 @@
+#!/bin/bash
+
+cd ~
+
+# Rename the old 'owl' folder to 'owl-DATE'
+if [ -d "owl" ]; then
+ mv owl "owl_$(date +'%Y%m%d_%H%M%S')"
+fi
+
+# Download the new software from GitHub
+git clone https://github.com/geezacoleman/OpenWeedLocator owl
+cd ~/owl
+
+# update the system
+echo "[INFO] Upgrading Raspberry Pi system...this may take some time. You will be asked to confirm at some steps."
+sudo apt-get update
+sudo apt-get upgrade
+
+# Installing the requirements
+echo "[INFO] Upgrading OWL requirements."
+source `which workon` owl
+pip install -r requirements.txt
+
+# Changing permissions to make files executable
+chmod a+x owl.py
+chmod a+x owl_boot.sh
+
+echo "[COMPLETE] OWL update has executed successfully."
diff --git a/utils/algorithms.py b/utils/algorithms.py
new file mode 100644
index 0000000..9719c13
--- /dev/null
+++ b/utils/algorithms.py
@@ -0,0 +1,341 @@
+import numpy as np
+import cv2
+
+### Adding a new algorithm ###
+"""
+To add a new algorithm the only requirement is that it accepts a BGR (opencv) image and returns a grayscale
+image as an output. If it returns a binary image (like hsv) then it must return a boolean True in addition to the image
+as it has already been thresholded.
+"""
+##############################
+
+def exg(image):
+ """
+ Takes an image and processes it using ExG. Returns a single channel exG output.
+ Developed by Woebbecke et al. 1995.
+ :return: grayscale image
+ """
+ # using array slicing to split into channels
+ blue = image[:, :, 0].astype(np.float32)
+ green = image[:, :, 1].astype(np.float32)
+ red = image[:, :, 2].astype(np.float32)
+ # cv2.imshow('blue', blue.astype('uint8'))
+ # cv2.imshow('green', green.astype('uint8'))
+ # cv2.imshow('red', red.astype('uint8'))
+
+ image_out = 2 * green - red - blue
+ image_out = np.clip(image_out, 0, 255)
+ image_out = image_out.astype('uint8')
+
+ # cv2.imshow('ExG', imgOut)
+ return image_out
+
+def maxg(image):
+ '''
+ Takes an input image in int8 format and calculates the 'maxg' algorithm based on the following publication:
+ 'Weed Identification Using Deep Learning and Image Processing in Vegetable Plantation', Jin et al. 2021
+ :param image: image as a BGR array (i.e. opened with opencv not PIL)
+ :return: grayscale image
+ '''
+ # using array slicing to split into channels with float32 for calculation
+ blue = image[:, :, 0].astype(np.float32)
+ green = image[:, :, 1].astype(np.float32)
+ red = image[:, :, 2].astype(np.float32)
+
+ image_out = 24 * green - 19 * red - 2 * blue
+ image_out = (image_out / np.amax(image_out)) * 255 # scale image between 0 - 255
+ image_out = image_out.astype('uint8')
+
+ return image_out
+
+def exg_standardised(image):
+ '''
+ Takes an input image in int8 format and calculates the standardised ExG algorithm
+ :param image: image as a BGR array (i.e. opened with opencv not PIL)
+ :return: returns a grayscale image
+ '''
+ blue = image[:, :, 0].astype(np.float32)
+ green = image[:, :, 1].astype(np.float32)
+ red = image[:, :, 2].astype(np.float32)
+ channel_sum = red + green + blue
+ channel_sum = np.where(channel_sum == 0, 1, channel_sum)
+
+ b = blue / channel_sum
+ g = green / channel_sum
+ r = red / channel_sum
+
+ image_out = 255 * (2 * g - r - b)
+ image_out = np.where(image_out < 0, 0, image_out)
+ image_out = np.where(image_out > 255, 255, image_out)
+
+ image_out = image_out.astype('uint8')
+ # cv2.imshow('ExG Standardised', imgOut)
+
+ return image_out
+
+def exg_standardised_hue(image,
+ hue_min=30,
+ hue_max=90,
+ brightness_min=10,
+ brightness_max=220,
+ saturation_min=30,
+ saturation_max=255,
+ invert_hue=False):
+ '''
+ Takes an image and performs a combined ExG + HSV algorithm
+ :param image: image as a BGR array (i.e. opened with opencv not PIL)
+ :param hue_min: minimum hue value
+ :param hue_max: maximum hue value
+ :param brightness_min: minimum 'value' or brightness value
+ :param brightness_max: maximum 'value' or brightness value
+ :param saturation_min: minimum saturation
+ :param saturation_max: maximum saturation
+ :param invert_hue: inverts the hue threshold to exclude anything within the thresholds
+ :return: returns a grayscale image
+ '''
+
+ blue = image[:, :, 0].astype(np.float32)
+ green = image[:, :, 1].astype(np.float32)
+ red = image[:, :, 2].astype(np.float32)
+
+ channel_sum = red + green + blue
+ channel_sum = np.where(channel_sum == 0, 1, channel_sum)
+
+ b = blue / channel_sum
+ g = green / channel_sum
+ r = red / channel_sum
+
+ image_out = 255 * (2 * g - r - b)
+ image_out = np.where(image_out < 0, 0, image_out)
+ image_out = np.where(image_out > 255, 255, image_out)
+
+ image_out = image_out.astype('uint8')
+
+ hsv_thresh, _ = hsv(image,
+ hue_min=hue_min, hue_max=hue_max,
+ brightness_min=brightness_min, brightness_max=brightness_max,
+ saturation_min=saturation_min, saturation_max=saturation_max,
+ invert_hue=invert_hue)
+ image_out = hsv_thresh & image_out
+ # cv2.imshow('exhu', imgOut)
+
+ return image_out
+
+def exgr(image):
+ '''
+ performs the ExGR algorithm on the input image
+ :param image: image as a BGR array (i.e. opened with opencv not PIL)
+ :return: returns a grayscale image
+ '''
+ green = image[:, :, 1].astype(np.float32)
+ red = image[:, :, 2].astype(np.float32)
+
+ exg_image = exg(image)
+ image_out = exg_image - (1.4 * red - green)
+
+ image_out = np.clip(image_out, 0, 255)
+ image_out = image_out.astype('uint8')
+
+ return image_out
+
+def hsv(image,
+ hue_min=30,
+ hue_max=90,
+ brightness_min=10,
+ brightness_max=220,
+ saturation_min=30,
+ saturation_max=255,
+ invert_hue=False):
+
+ """
+ Performs an HSV thresholding operation on the input image
+ :param image: image as a BGR array (i.e. opened with opencv not PIL)
+ :param hue_min: minimum hue threshold
+ :param hue_max: maximum hue threshold
+ :param brightness_min: minimum 'brightness' or 'value' threshold
+ :param brightness_max: maximum 'brightness' or 'value' threshold
+ :param saturation_min: minimum saturation threshold
+ :param saturation_max: maximum saturation threshold
+ :param invert_hue: inverts the hue threshold to exclude anything within the thresholds
+ :return: returns a binary image and boolean thresholded or not
+ """
+
+ image = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
+ hue = image[:, :, 0]
+ sat = image[:, :, 1]
+ val = image[:, :, 2]
+
+ hue_thresh = cv2.inRange(hue, hue_min, hue_max)
+ sat_thresh = cv2.inRange(sat, saturation_min, saturation_max)
+ val_thresh = cv2.inRange(val, brightness_min, brightness_max)
+
+ # allow users to select purple/red colour ranges by excluding green
+ if invert_hue:
+ hue_thresh = cv2.bitwise_not(hue_thresh)
+
+ out_thresh = sat_thresh & val_thresh & hue_thresh
+ # cv2.imshow('HSV Out', outThresh)
+ return out_thresh, True
+
+# for NIR images only
+def gndvi(image):
+ """
+ Takes an image and processes it using GNDVI. Returns a single channel grayscale scaled output.
+ :return:
+ """
+ # using array slicing to split into channel
+ green = image[:, :, 1].astype(np.float32)
+ NIR = image[:, :, 2].astype(np.float32)
+
+ image_out = (NIR - green) / (NIR + green)
+ image_out = cv2.normalize(image_out, None, alpha=0, beta=255, norm_type=cv2.NORM_MINMAX, dtype=cv2.CV_32F)
+ image_out = image_out.astype('uint8')
+ cv2.imshow('gndvi', image_out)
+ return image_out
+
+
+# Other vegetation indices are listed here, but have NOT been tested.
+def veg(image):
+ blue = image[:, :, 0].astype(np.float32)
+ green = image[:, :, 1].astype(np.float32)
+ red = image[:, :, 2].astype(np.float32)
+
+ image_out = green / ((red ** 0.667) * (blue ** 0.333))
+ image_out = cv2.normalize(image_out, None, alpha=0, beta=255, norm_type=cv2.NORM_MINMAX, dtype=cv2.CV_32F)
+ image_out = np.clip(image_out, 0, 255)
+ image_out = image_out.astype('uint8')
+
+ return image_out
+
+def cive(image):
+ blue = image[:, :, 0].astype(np.float32)
+ green = image[:, :, 1].astype(np.float32)
+ red = image[:, :, 2].astype(np.float32)
+
+ image_out = 0.441 * red - 0.881 * green + 0.385 * blue + 18.78745
+ #image_out = cv2.normalize(imgOut, None, alpha=0, beta=255, norm_type=cv2.NORM_MINMAX, dtype=cv2.CV_32F)
+ image_out = np.clip(image_out, 0, 255)
+ image_out = image_out.astype('uint8')
+
+ return image_out
+
+def clahe_sat_val(image):
+ image = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
+ hue = image[:, :, 0]
+ sat = image[:, :, 1]
+ val = image[:, :, 2]
+
+ clahe = cv2.createCLAHE(clipLimit=20, tileGridSize=(64,64))
+ satCL = clahe.apply(sat)
+ valCL = clahe.apply(val)
+
+ claheImage = cv2.merge([hue, satCL, valCL])
+ claheImage = cv2.cvtColor(claheImage, cv2.COLOR_HSV2BGR)
+ #cv2.imshow('CLAHE', claheImage)
+ return claheImage
+
+def dgci(image):
+ image = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
+
+ hue = image[:, :, 0].astype(np.float32)
+ sat = image[:, :, 1].astype(np.float32)
+ val = image[:, :, 2].astype(np.float32)
+
+ np.seterr(divide='ignore', invalid='ignore')
+ imgOut = ((hue - 60)/(60 + (1 - sat) + (1 - val)))/3
+
+ imgOut = imgOut.astype('uint8')
+ imgOut = cv2.normalize(imgOut, None, alpha=0, beta=255, norm_type=cv2.NORM_MINMAX, dtype=cv2.CV_32F)
+
+ return imgOut
+
+##### BLUR ALGORITHMS
+# some algorithms developed with the help of Chat-GPT!
+# used before passing image into blur algorithms
+def normalize_brightness(image, intensity=0.8):
+ img_yuv = cv2.cvtColor(image, cv2.COLOR_BGR2YUV)
+ img_yuv[:, :, 0] = cv2.equalizeHist(img_yuv[:, :, 0])
+ img_yuv[:, :, 0] = np.clip(intensity * img_yuv[:, :, 0], 0, 255)
+ normalized = cv2.cvtColor(img_yuv, cv2.COLOR_YUV2BGR)
+
+ # Return the normalized image
+ #stacked = np.hstack((image, normalized))
+ #cv2.imshow('normalised', stacked)
+ #cv2.waitKey(0)
+
+ return normalized
+
+def fft_blur(image, size=60):
+ """
+ Adapted from:
+ https://pyimagesearch.com/2020/06/15/opencv-fast-fourier-transform-fft-for-blur-detection-in-images-and-video-streams/
+ """
+ (h, w) = image.shape
+ (cX, cY) = (int(w / 2.0), int(h / 2.0))
+ fft = np.fft.fft2(image)
+ fftShift = np.fft.fftshift(fft)
+
+ fftShift[cY - size:cY + size, cX - size:cX + size] = 0
+ fftShift = np.fft.ifftshift(fftShift)
+ recon = np.fft.ifft2(fftShift)
+
+ magnitude = 20 * np.log(np.abs(recon))
+ mean = np.mean(magnitude)
+
+ return mean
+
+def laplacian_blur(image):
+ grey = cv2.cvtColor(image.copy(), cv2.COLOR_BGR2GRAY)
+ blurriness = cv2.Laplacian(grey, cv2.CV_64F).var()
+
+ return blurriness
+
+
+def variance_of_gradient_blur(image):
+ grey = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
+ sobelx = cv2.Sobel(grey, cv2.CV_64F, 1, 0, ksize=3)
+ sobely = cv2.Sobel(grey, cv2.CV_64F, 0, 1, ksize=3)
+ gradient_magnitude = np.sqrt(np.square(sobelx) + np.square(sobely))
+ blurriness = np.var(gradient_magnitude)
+
+ return blurriness
+
+
+def tenengrad_blur(image):
+ grey = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
+ sobelx = cv2.Sobel(grey, cv2.CV_64F, 1, 0, ksize=5)
+ sobely = cv2.Sobel(grey, cv2.CV_64F, 0, 1, ksize=5)
+ gradient_magnitude = np.sqrt(np.square(sobelx) + np.square(sobely))
+ blurriness = np.sum(np.square(gradient_magnitude)) / (grey.shape[0] * grey.shape[1])
+
+ return blurriness
+
+
+def entropy_blur(image):
+ grey = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
+ hist = cv2.calcHist([grey], [0], None, [256], [0, 256])
+ hist_norm = hist / (grey.shape[0] * grey.shape[1])
+ hist_norm = hist_norm[hist_norm != 0]
+ blurriness = -np.sum(hist_norm * np.log2(hist_norm))
+
+ return blurriness
+
+
+def wavelet_blur(image):
+ import pywt
+ grey = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
+ coeffs = pywt.dwt2(grey, 'haar')
+ LL, (LH, HL, HH) = coeffs
+ blurriness = np.sum(np.square(LL)) / (grey.shape[0] * grey.shape[1])
+
+ return blurriness
+
+
+def gradient_blur(image):
+ grey = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
+ sobelx = cv2.Sobel(grey, cv2.CV_64F, 1, 0, ksize=3)
+ sobely = cv2.Sobel(grey, cv2.CV_64F, 0, 1, ksize=3)
+ gradient_magnitude = np.sqrt(np.square(sobelx) + np.square(sobely))
+ blurriness = np.sum(gradient_magnitude) / (grey.shape[0] * grey.shape[1])
+
+ return blurriness
\ No newline at end of file
diff --git a/utils/config_manager.py b/utils/config_manager.py
new file mode 100644
index 0000000..b687450
--- /dev/null
+++ b/utils/config_manager.py
@@ -0,0 +1,440 @@
+from pathlib import Path
+from configparser import ConfigParser, Error as ConfigParserError
+from typing import Dict, Set, Tuple
+
+import logging
+import utils.error_manager as errors
+
+logger = logging.getLogger(__name__)
+
+class ConfigValidator:
+ """Validates OWL configuration files"""
+
+ REQUIRED_CONFIG = {
+ 'System': {
+ 'required_keys': {'algorithm', 'relay_num', 'actuation_duration', 'delay'},
+ 'optional_keys': {'input_file_or_directory'}
+ },
+ 'Controller': {
+ # Base requirements for all controller types
+ 'required_keys': {'controller_type'},
+ 'optional_keys': {
+ 'detection_mode_pin_up',
+ 'detection_mode_pin_down',
+ 'recording_pin',
+ 'sensitivity_pin',
+ 'low_sensitivity_config',
+ 'high_sensitivity_config',
+ 'switch_purpose',
+ 'switch_pin'
+ },
+ # Type-specific requirements
+ 'type_specific': {
+ 'none': {
+ 'required_keys': set(),
+ 'optional_keys': set()
+ },
+ 'ute': {
+ 'required_keys': {'switch_pin', 'switch_purpose'},
+ 'optional_keys': set()
+ },
+ 'advanced': {
+ 'required_keys': {
+ 'detection_mode_pin_up',
+ 'detection_mode_pin_down',
+ 'recording_pin',
+ 'sensitivity_pin',
+ 'low_sensitivity_config',
+ 'high_sensitivity_config'
+ },
+ 'optional_keys': set()
+ }
+ }
+ },
+ 'Camera': {
+ 'required_keys': {'resolution_width', 'resolution_height'},
+ 'optional_keys': {'exp_compensation'}
+ },
+ 'GreenOnBrown': {
+ 'required_keys': {
+ 'exg_min', 'exg_max', 'hue_min', 'hue_max',
+ 'saturation_min', 'saturation_max', 'brightness_min', 'brightness_max',
+ 'min_detection_area'
+ },
+ 'optional_keys': {'invert_hue'}
+ },
+ 'DataCollection': {
+ 'required_keys': {'sample_images', 'sample_method', 'save_directory'},
+ 'optional_keys': {'sample_frequency', 'disable_detection', 'log_fps', 'camera_name'}
+ },
+ 'Relays': {
+ 'required_keys': {'0', '1', '2', '3'},
+ 'optional_keys': set()
+ }
+ }
+
+ VALUE_VALIDATORS = {
+ # 8-bit values (0-255)
+ 'exg_min': ('int', 0, 255),
+ 'exg_max': ('int', 0, 255),
+ 'saturation_min': ('int', 0, 255),
+ 'saturation_max': ('int', 0, 255),
+ 'brightness_min': ('int', 0, 255),
+ 'brightness_max': ('int', 0, 255),
+ # Hue values (0-180)
+ 'hue_min': ('int', 0, 180),
+ 'hue_max': ('int', 0, 180),
+ # Resolution
+ 'resolution_width': ('int', 1, None),
+ 'resolution_height': ('int', 1, None),
+ # Camera settings
+ 'exp_compensation': ('float', -10, 10),
+ # Detection confidence
+ 'confidence': ('float', 0, 1),
+ # GPIO pins
+ 'switch_pin': ('pin', 1, 40),
+ 'detection_mode_pin_up': ('pin', 1, 40),
+ 'detection_mode_pin_down': ('pin', 1, 40),
+ 'recording_pin': ('pin', 1, 40),
+ 'sensitivity_pin': ('pin', 1, 40),
+ }
+
+ VALID_ALGORITHMS = {'exg', 'exgr', 'maxg', 'nexg', 'exhsv', 'hsv', 'gndvi', 'gog'}
+ VALID_CONTROLLER_TYPES = {'none', 'ute', 'advanced'}
+ VALID_SWITCH_PURPOSES = {'recording', 'sensitivity'}
+
+ # to check for valid ranges
+ THRESHOLD_PAIRS = [
+ ('exg_min', 'exg_max'),
+ ('hue_min', 'hue_max'),
+ ('saturation_min', 'saturation_max'),
+ ('brightness_min', 'brightness_max')
+ ]
+
+ @classmethod
+ def validate_controller(cls, config: ConfigParser) -> Tuple[bool, Dict[str, Dict[str, str]]]:
+ """Validate controller configuration."""
+ controller_errors: Dict[str, Dict[str, str]] = {} # Type hint for errors dictionary
+ controller_type = config.get('Controller', 'controller_type', fallback='').lower()
+
+ # Validate controller type
+ if not controller_type:
+ return False, {'Controller': {'controller_type': 'Controller type must be specified'}}
+
+ if controller_type not in cls.VALID_CONTROLLER_TYPES:
+ return False, {'Controller': {
+ 'controller_type': f'Invalid controller type. Must be one of: {", ".join(sorted(cls.VALID_CONTROLLER_TYPES))}'
+ }}
+
+ # For UTE controller, validate switch_purpose
+ if controller_type == 'ute' and config.has_option('Controller', 'switch_purpose'):
+ switch_purpose = config.get('Controller', 'switch_purpose').lower()
+ if switch_purpose not in cls.VALID_SWITCH_PURPOSES:
+ if 'Controller' not in controller_errors:
+ controller_errors['Controller'] = {}
+ controller_errors['Controller'][
+ 'switch_purpose'] = f'Must be one of: {", ".join(sorted(cls.VALID_SWITCH_PURPOSES))}'
+
+ # For advanced controller, validate config files exist
+ if controller_type == 'advanced':
+ for config_key in ['low_sensitivity_config', 'high_sensitivity_config']:
+ if config.has_option('Controller', config_key):
+ config_path = Path(config.get('Controller', config_key))
+ if not config_path.exists():
+ if 'Controller' not in controller_errors:
+ controller_errors['Controller'] = {}
+ controller_errors['Controller'][config_key] = f'Config file does not exist: {config_path}'
+
+ return not bool(controller_errors), controller_errors
+
+ @classmethod
+ def get_controller_requirements(cls, controller_type: str) -> Tuple[set, set]:
+ """Get combined base and type-specific requirements for a controller."""
+ base_required = cls.REQUIRED_CONFIG['Controller']['required_keys']
+ base_optional = cls.REQUIRED_CONFIG['Controller']['optional_keys']
+
+ type_config = cls.REQUIRED_CONFIG['Controller']['type_specific'].get(
+ controller_type,
+ {'required_keys': set(), 'optional_keys': set()}
+ )
+
+ return (
+ base_required | type_config['required_keys'],
+ base_optional | type_config['optional_keys']
+ )
+
+ @classmethod
+ def validate_algorithm(cls, config: ConfigParser) -> Tuple[bool, Dict[str, Dict[str, str]]]:
+ """Validate algorithm selection."""
+ algorithm = config.get('System', 'algorithm', fallback='').lower()
+ if not algorithm:
+ return False, {'System': {'algorithm': 'Algorithm must be specified'}}
+
+ if algorithm not in cls.VALID_ALGORITHMS:
+ return False, {'System': {
+ 'algorithm': f'Invalid algorithm. Must be one of: {", ".join(sorted(cls.VALID_ALGORITHMS))}'
+ }}
+
+ return True, {}
+
+ @classmethod
+ def validate_thresholds(cls, config: ConfigParser) -> Tuple[bool, Dict[str, Dict[str, str]]]:
+ """
+ Validate threshold relationships and detection ranges.
+ Returns (is_valid, errors)
+ """
+ ACCEPTABLE_RANGE = 5
+ threshold_errors = {}
+ section_errors = {}
+
+ # Validate min < max for all threshold pairs
+ for min_key, max_key in cls.THRESHOLD_PAIRS:
+ try:
+ min_val = config.getint('GreenOnBrown', min_key)
+ max_val = config.getint('GreenOnBrown', max_key)
+
+ if min_val >= max_val:
+ section_errors[f"{min_key}_{max_key}"] = (
+ f"{min_key} ({min_val}) must be less than {max_key} ({max_val})"
+ )
+ except (ValueError, ConfigParserError):
+ # Skip if values aren't valid integers - this will be caught by value validation
+ continue
+
+ # Validate detection ranges overlap
+ algorithm = config.get('System', 'algorithm', fallback='').lower()
+
+ # For HSV-based algorithms, check HSV ranges make sense together
+ if algorithm in {'hsv', 'exhsv'}:
+ try:
+ hue_range = range(config.getint('GreenOnBrown', 'hue_min'),
+ config.getint('GreenOnBrown', 'hue_max'))
+ sat_range = range(config.getint('GreenOnBrown', 'saturation_min'),
+ config.getint('GreenOnBrown', 'saturation_max'))
+ val_range = range(config.getint('GreenOnBrown', 'brightness_min'),
+ config.getint('GreenOnBrown', 'brightness_max'))
+
+ # Check if ranges are too restrictive
+ if len(hue_range) < ACCEPTABLE_RANGE:
+ section_errors['hue_range'] = 'Hue range is too narrow for reliable detection'
+ if len(sat_range) < ACCEPTABLE_RANGE:
+ section_errors['saturation_range'] = 'Saturation range is too narrow for reliable detection'
+ if len(val_range) < ACCEPTABLE_RANGE:
+ section_errors['brightness_range'] = 'Brightness range is too narrow for reliable detection'
+
+ except (ValueError, ConfigParserError):
+ # Skip if values aren't valid integers - this will be caught by value validation
+ pass
+
+ # For EXG-based algorithms, check EXG range
+ if algorithm in {'exg', 'exgr', 'maxg', 'nexg', 'exhsv'}:
+ try:
+ exg_range = range(config.getint('GreenOnBrown', 'exg_min'),
+ config.getint('GreenOnBrown', 'exg_max'))
+
+ if len(exg_range) < ACCEPTABLE_RANGE:
+ section_errors['exg_range'] = 'ExG range is too narrow for reliable detection'
+
+ except (ValueError, ConfigParserError):
+ pass
+
+ if section_errors:
+ threshold_errors['GreenOnBrown'] = section_errors
+
+ return not bool(threshold_errors), threshold_errors
+
+ @classmethod
+ def validate_value(cls, key: str, value: str, used_pins: Set[int]) -> Tuple[bool, str]:
+ """Validate a single config value."""
+ if key not in cls.VALUE_VALIDATORS:
+ return True, ""
+
+ val_type, min_val, max_val = cls.VALUE_VALIDATORS[key]
+
+ try:
+ if val_type == 'int':
+ val = int(value)
+ if min_val is not None and val < min_val:
+ return False, f"Value must be >= {min_val}"
+ if max_val is not None and val > max_val:
+ return False, f"Value must be <= {max_val}"
+
+ elif val_type == 'float':
+ val = float(value)
+ if min_val is not None and val < min_val:
+ return False, f"Value must be >= {min_val}"
+ if max_val is not None and val > max_val:
+ return False, f"Value must be <= {max_val}"
+
+ elif val_type == 'pin':
+ val = int(value)
+ if min_val is not None and val < min_val:
+ return False, f"Pin must be >= {min_val}"
+ if max_val is not None and val > max_val:
+ return False, f"Pin must be <= {max_val}"
+ if val in used_pins:
+ return False, f"Pin {val} is already in use"
+ used_pins.add(val)
+
+ except ValueError:
+ return False, f"Must be a valid {val_type}"
+
+ return True, ""
+
+ @classmethod
+ def validate_relays(cls, config: ConfigParser) -> Tuple[bool, Dict[str, Dict[str, str]], list[str]]:
+ """
+ Validate relay configuration between System.relay_num and Relays section.
+ Returns:
+ Tuple containing:
+ - bool: whether validation passed
+ - Dict[str, Dict[str, str]]: nested dictionary of section -> {key: error_message}
+ - list[str]: list of warning messages
+ """
+ try:
+ relay_num = config.getint('System', 'relay_num')
+ if relay_num < 0:
+ return False, {'System': {'relay_num': 'Must be a non-negative integer'}}, []
+ except ValueError:
+ return False, {'System': {'relay_num': 'Must be a valid integer'}}, []
+
+ # Get available relays (keys should be '0', '1', etc.)
+ available_relays = set(config['Relays'].keys())
+
+ # Validate relay keys are proper integers
+ try:
+ for relay in available_relays:
+ _ = int(relay)
+ except ValueError:
+ return False, {'Relays': {'format': 'Relay keys must be integers (0, 1, 2, etc.)'}}, []
+
+ configured_relays = {str(i) for i in range(relay_num)}
+
+ # Check if requesting more relays than configured
+ if relay_num > len(available_relays):
+ return False, {
+ 'System': {
+ 'relay_num': f'Requests {relay_num} relays but only {len(available_relays)} are configured in [Relays] section'
+ }
+ }, []
+
+ # If requesting fewer relays than configured, generate warning about unused relays
+ warnings = []
+ if relay_num < len(available_relays):
+ unused_relays = available_relays - configured_relays
+ warnings.append(
+ f"Only using {relay_num} relays but {len(available_relays)} are configured. "
+ f"Unused relays: {', '.join(sorted(unused_relays))}"
+ )
+
+ # Validate that required relay numbers exist
+ missing_relays = configured_relays - available_relays
+ if missing_relays:
+ return False, {
+ 'Relays': {
+ 'missing': f'Missing configurations for relays: {", ".join(sorted(missing_relays))}'
+ }
+ }, []
+
+ return True, {}, warnings
+
+ @classmethod
+ def load_and_validate_config(cls, config_path: Path) -> ConfigParser:
+ """Load and validate configuration file."""
+ config = ConfigParser()
+ used_pins = set()
+ validation_errors = {}
+
+ # File existence and parsing must still raise immediately
+ # as we can't continue without a valid file
+ if not config_path.exists():
+ raise errors.ConfigFileError(config_path, "File does not exist")
+
+ try:
+ files_read = config.read(config_path)
+ if not files_read:
+ raise errors.ConfigFileError(config_path, "File could not be read")
+ except ConfigParserError as e:
+ raise errors.ConfigFileError(config_path, f"Parse error: {str(e)}")
+
+ # Create working copy of config requirements
+ working_config = dict(cls.REQUIRED_CONFIG)
+
+ # Validate controller specific rules
+ is_valid, controller_errors = cls.validate_controller(config)
+ if not is_valid:
+ validation_errors.update(controller_errors)
+
+ # Update controller requirements based on type
+ controller_type = config.get('Controller', 'controller_type', fallback='').lower()
+ required_keys, optional_keys = cls.get_controller_requirements(controller_type)
+ working_config['Controller'] = {
+ 'required_keys': required_keys,
+ 'optional_keys': optional_keys
+ }
+
+ # Validate algorithm
+ is_valid, algorithm_errors = cls.validate_algorithm(config)
+ if not is_valid:
+ validation_errors.update(algorithm_errors)
+
+ # Threshold validation
+ is_valid, threshold_errors = cls.validate_thresholds(config)
+ if not is_valid:
+ validation_errors.update(threshold_errors)
+
+ # Check required sections
+ missing_sections = set(working_config.keys()) - set(config.sections())
+ if missing_sections:
+ validation_errors['missing_sections'] = {
+ 'sections': f"Missing required sections: {', '.join(missing_sections)}"
+ }
+
+ # Validate sections and values
+ for section in config.sections():
+ section_errors = {}
+ for key, value in config[section].items():
+ is_valid, error_msg = cls.validate_value(key, value, used_pins)
+ if not is_valid:
+ section_errors[key] = value + f" - {error_msg}"
+ if section_errors:
+ validation_errors[section] = section_errors
+
+ # Validate relay configuration
+ is_valid, relay_errors, relay_warnings = cls.validate_relays(config)
+ if not is_valid:
+ validation_errors.update(relay_errors)
+
+ # Log any relay warnings
+ for warning in relay_warnings:
+ logger.warning(warning)
+
+ # Check required keys in each section
+ for section, requirements in working_config.items():
+ if section not in config.sections():
+ continue # Skip if section is missing - we've already recorded this error
+
+ config_keys = set(config[section].keys())
+ required_keys = {k.lower() for k in requirements['required_keys']}
+ optional_keys = {k.lower() for k in requirements['optional_keys']}
+
+ missing_keys = required_keys - config_keys
+ if missing_keys:
+ if section not in validation_errors:
+ validation_errors[section] = {}
+ validation_errors[section].update({
+ k: "Required key missing" for k in missing_keys
+ })
+
+ unknown_keys = config_keys - (required_keys | optional_keys)
+ if unknown_keys:
+ logger.warning(
+ f"Unknown keys in section [{section}]: {', '.join(unknown_keys)}"
+ )
+
+ # Raise all validation errors at once
+ if validation_errors:
+ raise errors.ConfigValueError(validation_errors, config_path)
+
+ logger.info(f"Successfully loaded and validated config: {config_path}")
+ return config
\ No newline at end of file
diff --git a/utils/directory_manager.py b/utils/directory_manager.py
new file mode 100644
index 0000000..6fe108b
--- /dev/null
+++ b/utils/directory_manager.py
@@ -0,0 +1,110 @@
+from datetime import datetime
+import utils.error_manager as errors
+import platform
+import time
+import os
+
+from utils.log_manager import LogManager
+
+
+class DirectorySetup:
+ def __init__(self, save_directory):
+ self.logger = LogManager.get_logger(__name__)
+ self.save_directory = save_directory
+ self.save_subdirectory = None
+
+ def setup_directories(self, max_retries=5, retry_delay=2):
+ for attempt in range(max_retries):
+ try:
+ return self._try_setup_directories()
+ except (errors.USBMountError, errors.USBWriteError, errors.NoWritableUSBError) as e:
+ self.logger.info(f"[INFO] Attempt {attempt + 1} failed: {str(e)}. Retrying in {retry_delay} seconds...")
+ time.sleep(retry_delay)
+
+ raise errors.NoWritableUSBError()
+
+ def _try_setup_directories(self):
+ self.save_subdirectory = os.path.join(self.save_directory, datetime.now().strftime('%Y%m%d'))
+ if not os.path.ismount(self.save_directory):
+ return self._handle_mount_error()
+
+ os.makedirs(self.save_subdirectory, exist_ok=True)
+ if not self.test_file_write():
+ raise errors.USBWriteError("Failed to write test file")
+
+ self.logger.info(f"[SUCCESS] Directory setup complete: {self.save_subdirectory}")
+ return self.save_directory, self.save_subdirectory
+
+ def _handle_mount_error(self):
+ """
+ Handle USB mount errors on Raspberry Pi systems.
+ Searches /media directory for mounted, writable USB drives.
+ """
+ if platform.system() != 'Linux':
+ raise errors.StorageSystemError(platform=platform.system())
+
+ media_dir = '/media'
+ try:
+ mounted_drives = self._find_mounted_drives(media_dir)
+ except OSError as e:
+ raise errors.USBMountError(device=media_dir) from e
+
+ for drive_path in mounted_drives:
+ if self._try_setup_drive(drive_path):
+ return self.save_directory, self.save_subdirectory
+
+ raise errors.NoWritableUSBError(searched_paths=[media_dir])
+
+ def _find_mounted_drives(self, media_dir: str) -> list[str]:
+ """Find all mounted drives in the media directory."""
+ mounted_drives = []
+
+ try:
+ for username in os.listdir(media_dir):
+ user_media_dir = os.path.join(media_dir, username)
+ if not os.path.isdir(user_media_dir):
+ continue
+
+ for drive in os.listdir(user_media_dir):
+ drive_path = os.path.join(user_media_dir, drive)
+ if os.path.ismount(drive_path):
+ mounted_drives.append(drive_path)
+ except OSError as e:
+ self.logger.error(f"Error accessing media directory: {e}", exc_info=True)
+
+ return mounted_drives
+
+ def _try_setup_drive(self, drive_path: str) -> bool:
+ """
+ Try to setup a specific drive for writing.
+
+ Returns:
+ bool: True if drive is writable and setup successful
+ """
+ self.save_directory = drive_path
+ self.save_subdirectory = os.path.join(
+ self.save_directory,
+ datetime.now().strftime('%Y%m%d')
+ )
+
+ try:
+ os.makedirs(self.save_subdirectory, exist_ok=True)
+ if self.test_file_write():
+ self.logger.info(f'Connected to {drive_path} and it is writable.')
+ return True
+ self.logger.error(f'{drive_path} is connected but not writable.')
+ except PermissionError:
+ self.logger.error(f'Failed to access {drive_path}', exc_info=True)
+
+ return False
+
+ def test_file_write(self):
+ test_file_path = os.path.join(self.save_subdirectory, 'test_write.txt')
+ try:
+ with open(test_file_path, 'w') as f:
+ f.write('Test write successful')
+ os.remove(test_file_path)
+ return True
+ except Exception as e:
+ self.logger.error(f"[ERROR] Failed to write test file: {e}", exc_info=True)
+ return False
\ No newline at end of file
diff --git a/utils/error_manager.py b/utils/error_manager.py
new file mode 100644
index 0000000..a90202e
--- /dev/null
+++ b/utils/error_manager.py
@@ -0,0 +1,919 @@
+import subprocess
+import traceback
+import logging
+import sys
+import os
+
+from pathlib import Path
+from typing import List, Optional, Dict, Any, Set
+from datetime import datetime
+from dataclasses import dataclass
+
+# Base exception class for all OWL-specific errors
+class OWLError(Exception):
+ """Base exception class for all OWL-related errors."""
+
+ # Color definitions available to all OWL errors
+ COLORS = {
+ 'RED': "\033[91m",
+ 'GREEN': "\033[92m",
+ 'YELLOW': "\033[93m",
+ 'BLUE': "\033[94m",
+ 'PURPLE': "\033[95m",
+ 'CYAN': "\033[96m",
+ 'WHITE': "\033[97m",
+ 'RESET': "\033[0m",
+ 'BOLD': "\033[1m",
+ 'UNDERLINE': "\033[4m"
+ }
+
+ def __init__(self, message: str = None, details: Dict[str, Any] = None):
+ self.details = details or {}
+ self.timestamp = datetime.now()
+ self.error_id = f"OWL_{self.timestamp.strftime('%Y%m%d_%H%M%S')}"
+ super().__init__(message)
+
+ @classmethod
+ def colorize(cls, text: str, color: str, bold: bool = False, underline: bool = False) -> str:
+ """
+ Apply color and text formatting to a string.
+
+ Args:
+ text: The text to colorize
+ color: Color name from COLORS dict
+ bold: Whether to make the text bold
+ underline: Whether to underline the text
+ """
+ formatting = ""
+ if bold:
+ formatting += cls.COLORS['BOLD']
+ if underline:
+ formatting += cls.COLORS['UNDERLINE']
+
+ color_code = cls.COLORS.get(color.upper(), '')
+ return f"{formatting}{color_code}{text}{cls.COLORS['RESET']}"
+
+ def format_error_header(self, title: str) -> str:
+ """Create a standardized error header."""
+ return (
+ f"\n{self.colorize(title, 'RED', bold=True)}\n"
+ f"{self.colorize(f'Error ID: {self.error_id}', 'YELLOW')}\n"
+ )
+
+ def format_section(self, title: str, content: str) -> str:
+ """Create a standardized section in the error message."""
+ return (
+ f"\n{self.colorize(title + ':', 'GREEN')}\n"
+ f"{content}\n"
+ )
+
+### HARDWARE RELATED ERRORS ###
+
+@dataclass
+class ProcessInfo:
+ pid: int
+ command: str
+
+
+class StorageError(OWLError):
+ """Base class for storage-related errors"""
+ pass
+
+
+class USBError(StorageError):
+ """Base class for USB-related errors"""
+ pass
+
+
+class USBMountError(USBError):
+ """Raised when there are issues mounting a USB device"""
+
+ def __init__(self, device: str = None, message: str = None):
+ super().__init__(
+ message=None,
+ details={'device': device} if device else {}
+ )
+
+ if not message:
+ message = (
+ self.format_error_header("USB Mount Error") +
+ self.format_section(
+ "Problem",
+ f"Failed to mount USB device: {self.colorize(str(device), 'WHITE', bold=True)}"
+ ) +
+ self.format_section(
+ "Solutions",
+ "1. Check if USB device is properly connected\n"
+ "2. Verify device permissions\n"
+ "3. Check system mount points"
+ )
+ )
+
+ self.args = (message,)
+
+
+class USBWriteError(USBError):
+ """Raised when there are issues writing to a USB device"""
+
+ def __init__(self, device: str = None, message: str = None):
+ super().__init__(
+ message=None,
+ details={'device': device} if device else {}
+ )
+
+ if not message:
+ message = (
+ self.format_error_header("USB Write Error") +
+ self.format_section(
+ "Problem",
+ f"Failed to write to USB device: {self.colorize(str(device), 'WHITE', bold=True)}"
+ ) +
+ self.format_section(
+ "Solutions",
+ "1. Check if device is write-protected\n"
+ "2. Verify available space\n"
+ "3. Check file system permissions"
+ )
+ )
+
+ self.args = (message,)
+
+
+class NoWritableUSBError(USBError):
+ """Raised when no writable USB devices are found"""
+
+ def __init__(self, searched_paths: list[str] = None):
+ super().__init__(
+ message=None,
+ details={'searched_paths': searched_paths} if searched_paths else {}
+ )
+
+ message = (
+ self.format_error_header("No Writable USB Devices") +
+ self.format_section(
+ "Problem",
+ "No writable USB devices were found"
+ )
+ )
+
+ if searched_paths:
+ message += self.format_section(
+ "Searched Locations",
+ "\n".join(f"• {path}" for path in searched_paths)
+ )
+
+ message += self.format_section(
+ "Solutions",
+ "1. Check if USB device is properly connected\n"
+ "2. Verify USB device is not write-protected\n"
+ "3. Ensure USB device is properly formatted\n"
+ "4. Check device permissions"
+ )
+
+ self.args = (message,)
+
+
+class StorageSystemError(StorageError):
+ """Raised when there are platform/system compatibility issues with storage"""
+
+ def __init__(self, platform: str = None, message: str = None):
+ super().__init__(
+ message=None,
+ details={'platform': platform} if platform else {}
+ )
+
+ if not message:
+ message = (
+ self.format_error_header("Storage System Compatibility Error") +
+ self.format_section(
+ "Problem",
+ f"Storage operation not supported on {self.colorize(platform, 'WHITE', bold=True)} platform"
+ ) +
+ self.format_section(
+ "Required",
+ "This operation requires Linux/Raspberry Pi"
+ ) +
+ self.format_section(
+ "Solutions",
+ "1. Use a supported platform, or\n"
+ "2. Specify a valid local directory path in config file\n"
+ "3. Use --save-directory flag to set local path"
+ )
+ )
+
+ self.args = (message,)
+
+### PROCESS RELATED ERRORS ###
+class OWLProcessError(OWLError):
+ """Base class for process-related errors."""
+ pass
+
+
+class OWLAlreadyRunningError(OWLProcessError):
+ """Raised when OWL is already running."""
+
+ @staticmethod
+ def get_owl_processes() -> List[ProcessInfo]:
+ """Get information about running OWL processes."""
+ try:
+ result = subprocess.check_output(['ps', '-eo', 'pid,command'], text=True).splitlines()
+ return [
+ ProcessInfo(pid=int(parts[0]), command=' '.join(parts[1:]))
+ for line in result
+ if 'owl.py' in line
+ and len(parts := line.strip().split()) >= 2
+ and parts[0].isdigit()
+ ]
+ except subprocess.CalledProcessError:
+ return []
+
+ def __init__(self, message: Optional[str] = None):
+ processes = self.get_owl_processes()
+
+ super().__init__(
+ message=None,
+ details={'running_processes': [vars(p) for p in processes]}
+ )
+
+ process_list = "\n".join(
+ f" {self.colorize(f'PID: {proc.pid}', 'WHITE', bold=True)} - Command: {proc.command}"
+ for proc in processes
+ ) or " No OWL processes found in PS output."
+
+ formatted_message = (
+ self.format_error_header("OWL Process Already Running") +
+ self.format_section(
+ "Status",
+ "Another instance of OWL appears to be running. The GPIO pins are in use."
+ ) +
+ self.format_section(
+ "Running OWL Processes",
+ process_list
+ ) +
+ self.format_section(
+ "Commands to Stop",
+ f" {self.colorize('kill ', 'WHITE', bold=True)} - Graceful termination\n"
+ f" {self.colorize('kill -9 ', 'WHITE', bold=True)} - Force termination (use with caution)"
+ ) +
+ self.format_section(
+ "Important Notes",
+ "- Double-check the PID before stopping it!\n"
+ "- If no processes are listed but error persists, check GPIO outputs\n"
+ "- Ensure all GPIO resources are properly released\n"
+ "- Try rebooting if the issue persists"
+ )
+ )
+
+ self.args = (formatted_message,)
+
+
+class OWLControllerError(OWLError):
+ """Base class for controller-related errors."""
+ pass
+
+
+class ControllerPinError(OWLControllerError):
+ """Raised when there are issues with controller GPIO pins."""
+
+ def __init__(self, pin_name: str, pin_number: int = None, reason: str = None):
+ # Call super first
+ super().__init__(
+ message=None,
+ details={
+ 'pin_name': pin_name,
+ 'pin_number': pin_number,
+ 'reason': reason
+ }
+ )
+
+ message = (
+ self.format_error_header("GPIO Pin Error") +
+ self.format_section(
+ "Pin Details",
+ f"• Name: {self.colorize(pin_name, 'WHITE', bold=True)}\n" +
+ (f"• Number: {self.colorize(f'BOARD{pin_number}', 'WHITE', bold=True)}\n" if pin_number else "") +
+ (f"• Reason: {reason}\n" if reason else "")
+ ) +
+ self.format_section(
+ "Common Fixes",
+ "1. Check for pin conflicts with other processes\n"
+ "2. Verify physical connections\n"
+ "3. Confirm pin numbers in config"
+ )
+ )
+ self.args = (message,)
+
+
+class ControllerConfigError(OWLControllerError):
+ """Raised when there are issues with controller configuration."""
+
+ def __init__(self, config_key: str, section: str = "Controller"):
+ # Call super first
+ super().__init__(
+ message=None,
+ details={
+ 'config_key': config_key,
+ 'section': section
+ }
+ )
+
+ message = (
+ self.format_error_header("Controller Configuration Error") +
+ self.format_section(
+ "Missing Configuration",
+ f"Required key '{self.colorize(config_key, 'WHITE', bold=True)}' "
+ f"not found in section [{self.colorize(section, 'WHITE', bold=True)}]"
+ ) +
+ self.format_section(
+ "Fix",
+ "1. Check your config.ini file\n"
+ f"2. Add the missing {config_key} setting in [{section}] section\n"
+ "3. Ensure the value is appropriate for your controller type"
+ )
+ )
+ self.args = (message,)
+
+
+class OWLConfigError(OWLError):
+ """Base class for config file errors"""
+ pass
+
+
+class ConfigFileError(OWLConfigError):
+ """Raised when there are issues with the config file itself"""
+ def __init__(self, config_path: Path, reason: str = None):
+ # First initialize parent
+ super().__init__(
+ message=None,
+ details={
+ 'config_path': str(config_path),
+ 'reason': reason
+ }
+ )
+
+ # Now build message using parent's methods
+ message = (
+ self.format_error_header("Configuration File Error") +
+ self.format_section(
+ "Problem",
+ f"Cannot load configuration file: {self.colorize(str(config_path), 'WHITE', bold=True)}\n"
+ f"Reason: {reason if reason else 'File not found or inaccessible'}"
+ ) +
+ self.format_section(
+ "Fix",
+ "1. Verify the config file exists\n"
+ "2. Check file permissions\n"
+ "3. Ensure the file path is correct\n"
+ "4. Verify the file is not corrupted"
+ )
+ )
+ self.args = (message,) # Update Exception's message
+
+
+class ConfigSectionError(OWLConfigError):
+ """Raised when required sections are missing"""
+ def __init__(self, missing_sections: Set[str], config_path: Path):
+ super().__init__(
+ message=None,
+ details={
+ 'missing_sections': list(missing_sections),
+ 'config_path': str(config_path)
+ }
+ )
+
+ message = (
+ self.format_error_header("Missing Configuration Sections") +
+ self.format_section(
+ "Problem",
+ f"Required sections missing from {self.colorize(str(config_path), 'WHITE', bold=True)}:\n" +
+ "\n".join(f"• {self.colorize(section, 'WHITE', bold=True)}"
+ for section in missing_sections)
+ ) +
+ self.format_section(
+ "Fix",
+ "Add the missing sections to your config file with appropriate settings"
+ )
+ )
+ self.args = (message,)
+
+
+class ConfigKeyError(OWLConfigError):
+ """Raised when required keys are missing in a section"""
+ def __init__(self, section: str, missing_keys: Set[str], config_path: Path):
+ super().__init__(
+ message=None,
+ details={
+ 'section': section,
+ 'missing_keys': list(missing_keys),
+ 'config_path': str(config_path)
+ }
+ )
+
+ message = (
+ self.format_error_header("Missing Configuration Keys") +
+ self.format_section(
+ "Problem",
+ f"Required keys missing from section [{self.colorize(section, 'WHITE', bold=True)}]:\n" +
+ "\n".join(f"• {self.colorize(key, 'WHITE', bold=True)}"
+ for key in missing_keys)
+ ) +
+ self.format_section(
+ "Fix",
+ f"Add the missing keys to the [{section}] section of your config file"
+ )
+ )
+ self.args = (message,)
+
+
+class ConfigValueError(OWLConfigError):
+ """Raised when configuration values are invalid"""
+ def __init__(self, section_errors: Dict[str, Dict[str, str]], config_path: Path):
+ super().__init__(
+ message=None,
+ details={
+ 'section_errors': section_errors,
+ 'config_path': str(config_path)
+ }
+ )
+
+ error_lines = []
+ for section, errors in section_errors.items():
+ for key, error_msg in errors.items():
+ error_lines.append(
+ f"[{self.colorize(section, 'WHITE', bold=True)}] "
+ f"{self.colorize(key, 'WHITE', bold=True)} = {error_msg}"
+ )
+
+ message = (
+ self.format_error_header("Invalid Configuration Values") +
+ self.format_section(
+ "Problem",
+ "The following configuration values are invalid:\n" +
+ "\n".join(f"• {line}" for line in error_lines)
+ ) +
+ self.format_section(
+ "Fix",
+ f"Correct these values in your config file to be within their expected ranges"
+ )
+ )
+ self.args = (message,)
+
+
+class AlgorithmError(OWLError):
+ """Base class for algorithm-related errors"""
+
+ ERROR_MESSAGES = {
+ ModuleNotFoundError: {
+ 'coral': {
+ 'message': "Coral AI device support not installed",
+ 'details': "Visit: https://coral.ai/docs/accelerator/get-started/#requirements",
+ 'fix': "Install pycoral using: pip install pycoral"
+ }
+ },
+ (IndexError, FileNotFoundError): {
+ 'models': {
+ 'message': "Model files not found",
+ 'details': "Required model files are missing from the 'models' directory",
+ 'fix': "Ensure model files are present in the 'models' directory"
+ }
+ },
+ ValueError: {
+ 'delegate': {
+ 'message': "Coral AI device not recognized",
+ 'details': "Google Coral device connection issue",
+ 'fix': (
+ "1. Check device connection\n"
+ "2. Try unplugging and reconnecting the device\n"
+ "3. Restart the Raspberry Pi\n"
+ "More info: https://github.com/tensorflow/tensorflow/issues/32743"
+ )
+ }
+ }
+ }
+
+ def __init__(self, algorithm: str, error: Exception):
+ super().__init__(
+ message=None,
+ details={
+ 'algorithm': algorithm,
+ 'error_type': type(error).__name__,
+ 'original_error': str(error)
+ }
+ )
+
+ self.algorithm = algorithm
+ self.original_error = error
+ self.error_type = type(error)
+
+ error_config = self._get_error_config(error)
+ self.message = self._format_error_message(error_config)
+
+ self.args = (self.message,)
+
+ def _get_error_config(self, error: Exception) -> dict:
+ """Find the matching error configuration."""
+ for error_types, configs in self.ERROR_MESSAGES.items():
+ if isinstance(error, error_types):
+ if isinstance(error, ValueError) and 'delegate' in str(error):
+ return configs['delegate']
+ return next(iter(configs.values()))
+ return {
+ 'message': "Unrecognized algorithm error",
+ 'details': str(error),
+ 'fix': "Check the error message and logs for more information"
+ }
+
+ def _format_error_message(self, config: dict) -> str:
+ """Format the error message with the configuration."""
+ return (
+ self.format_error_header(f"Algorithm Error: {config['message']}") +
+ self.format_section(
+ "Algorithm",
+ f"Failed to initialize algorithm: {self.colorize(self.algorithm, 'WHITE', bold=True)}"
+ ) +
+ self.format_section(
+ "Details",
+ f"{config['details']}\n"
+ f"Original error: {self.colorize(str(self.original_error), 'WHITE')}"
+ ) +
+ self.format_section(
+ "Fix",
+ config['fix']
+ )
+ )
+
+ def handle(self, owl_instance) -> None:
+ """
+ Handle algorithm errors with appropriate logging and actions.
+
+ Args:
+ owl_instance: The Owl instance that encountered the error.
+ """
+ logger = getattr(owl_instance, 'logger', logging.getLogger(__name__))
+
+ logger.error(
+ self.message,
+ extra={
+ 'algorithm': self.algorithm,
+ 'error_type': self.error_type.__name__,
+ 'error_details': self.details
+ }
+ )
+
+ if logger.isEnabledFor(logging.DEBUG):
+ traceback_str = ''.join(traceback.format_exception(None, self.original_error, self.original_error.__traceback__))
+ logger.debug(
+ "Full error context",
+ extra={
+ 'traceback': traceback_str,
+ 'error_class': f"{self.error_type.__module__}.{self.error_type.__name__}"
+ }
+ )
+
+ if hasattr(owl_instance, 'stop'):
+ owl_instance.stop()
+ else:
+ logger.info("Exiting due to algorithm error.")
+ sys.exit(1)
+
+
+class OpenCVError(OWLError):
+ """Raised when there are issues with OpenCV (cv2) initialization or imports"""
+
+ def __init__(self, error_msg: str = None):
+ super().__init__(
+ message=None,
+ details={
+ 'original_error': error_msg,
+ 'virtual_env': os.environ.get('VIRTUAL_ENV'),
+ 'python_version': sys.version,
+ 'env_path': sys.prefix
+ }
+ )
+
+ message = (
+ self.format_error_header("OpenCV (cv2) Import Error") +
+ self.format_section(
+ "Problem",
+ f"Failed to import OpenCV (cv2)\n"
+ f"Error: {self.colorize(str(error_msg), 'WHITE', bold=True)}"
+ ) +
+ self.format_section(
+ "Likely Cause",
+ "You are not in the 'owl' virtual environment"
+ ) +
+ self.format_section(
+ "Solution",
+ "1. Activate the owl virtual environment:\n"
+ f" {self.colorize('workon owl', 'WHITE', bold=True)}\n\n"
+ "2. If the environment doesn't exist, create it with the owl_setup.sh:\n"
+ f" {self.colorize('bash owl_setup.sh', 'WHITE', bold=True)}\n"
+ "3. If opencv (cv2) is not yet installed in the environment, use owl_setup.sh:\n"
+ f" {self.colorize('bash owl_setup.sh', 'WHITE', bold=True)}\n"
+ f"3. or install it manually within the {self.colorize('(owl)', 'GREEN', bold=True)} environment:\n"
+ f" {self.colorize('pip install opencv-python', 'WHITE', bold=True)}\n"
+ ) +
+ self.format_section(
+ "Verify Environment",
+ f"After activation, you should see {self.colorize('(owl)', 'GREEN', bold=True)} "
+ "at the start of the command prompt.\nIf the error persists, raise an issue on the OpenWeedLocator"
+ "Github page:\nhttps://github.com/geezacoleman/OpenWeedLocator/issues"
+ )
+ )
+
+ self.args = (message,)
+
+ def handle(self, owl_instance=None) -> None:
+ """
+ Handle OpenCV errors with appropriate logging and actions.
+
+ Args:
+ owl_instance: Optional Owl instance that encountered the error.
+ """
+ if owl_instance and hasattr(owl_instance, 'logger'):
+ logger = owl_instance.logger
+ else:
+ logger = logging.getLogger(__name__)
+
+ logger.error(
+ str(self),
+ extra={
+ 'virtual_env': os.environ.get('VIRTUAL_ENV'),
+ 'python_version': sys.version,
+ 'env_path': sys.prefix,
+ 'error_details': self.details
+ }
+ )
+
+ if owl_instance and hasattr(owl_instance, 'stop'):
+ owl_instance.stop()
+ else:
+ logger.info("Exiting due to OpenCV import error.")
+ sys.exit(1)
+
+
+class DependencyError(OWLError):
+ """Raised when there are issues with Python package dependencies"""
+
+ PACKAGE_MAP = {
+ 'imutils': 'imutils',
+ 'cv2': 'opencv-python',
+ 'multiprocessing': 'multiprocessing',
+ 'pathlib': 'pathlib',
+ 'version': 'local version.py file',
+ 'SystemInfo': 'local version.py file',
+ 'FPS': 'imutils'
+ }
+
+ def __init__(self, missing_module: str, error_msg: str = None):
+ self.missing_module = missing_module
+ self.pip_package = self.PACKAGE_MAP.get(missing_module, missing_module)
+
+ super().__init__(
+ message=None,
+ details={
+ 'missing_module': missing_module,
+ 'pip_package': self.pip_package,
+ 'original_error': error_msg
+ }
+ )
+
+ # Build detailed error message
+ if self.pip_package.startswith('local'):
+ # Handle local file dependencies
+ message = self._format_local_file_error()
+ else:
+ # Handle pip installable packages
+ message = self._format_pip_package_error()
+
+ self.args = (message,)
+
+ def _format_pip_package_error(self) -> str:
+ """Format error message for pip installable packages"""
+ return (
+ self.format_error_header("Python Package Dependency Error") +
+ self.format_section(
+ "Problem",
+ f"Failed to import required module: {self.colorize(self.missing_module, 'WHITE', bold=True)}\n"
+ "This usually means the package is not installed in the owl virtual environment."
+ ) +
+ self.format_section(
+ "Quick Fix",
+ f"Install the missing package:\n"
+ f"1. Ensure you're in the owl environment:\n"
+ f" {self.colorize('workon owl', 'WHITE', bold=True)}\n"
+ f"2. Install the package:\n"
+ f" {self.colorize(f'pip install {self.pip_package}', 'WHITE', bold=True)}"
+ ) +
+ self.format_section(
+ "Complete Fix",
+ "Install all requirements:\n"
+ f"1. Activate owl environment: {self.colorize('workon owl', 'WHITE', bold=True)}\n"
+ f"2. Navigate to owl directory: {self.colorize('cd /path/to/owl', 'WHITE', bold=True)}\n"
+ f"3. Install requirements: {self.colorize('pip install -r requirements.txt', 'WHITE', bold=True)}"
+ ) +
+ self.format_section(
+ "Verify Installation",
+ f"Check if package is installed:\n"
+ f"{self.colorize(f'pip show {self.pip_package}', 'WHITE', bold=True)}"
+ )
+ )
+
+ def _format_local_file_error(self) -> str:
+ """Format error message for local file dependencies"""
+ return (
+ self.format_error_header("Local Module Import Error") +
+ self.format_section(
+ "Problem",
+ f"Failed to import local module: {self.colorize(self.missing_module, 'WHITE', bold=True)}\n"
+ "This usually means you're not in the correct directory or the file is missing."
+ ) +
+ self.format_section(
+ "Solution",
+ "1. Ensure you're in the owl environment:\n"
+ f" {self.colorize('workon owl', 'WHITE', bold=True)}\n"
+ "2. Navigate to the owl directory:\n"
+ f" {self.colorize('cd /path/to/owl', 'WHITE', bold=True)}\n"
+ "3. Verify the file exists:\n"
+ f" {self.colorize(f'ls {self.missing_module}.py', 'WHITE', bold=True)}"
+ ) +
+ self.format_section(
+ "If File is Missing",
+ "The file might have been deleted or not properly downloaded.\n"
+ "Try reinstalling OWL from the repository."
+ )
+ )
+
+ def handle(self, owl_instance=None) -> None:
+ """Handle dependency errors with appropriate logging and actions"""
+ if owl_instance and hasattr(owl_instance, 'logger'):
+ logger = owl_instance.logger
+ else:
+ logger = logging.getLogger(__name__)
+
+ # Log the error
+ logger.error(
+ str(self),
+ extra={
+ 'missing_module': self.missing_module,
+ 'pip_package': self.pip_package,
+ 'error_details': self.details
+ }
+ )
+
+ if owl_instance and hasattr(owl_instance, 'stop'):
+ owl_instance.stop()
+ else:
+ sys.exit(1)
+
+
+### MEDIA RELATED ERRORS ###
+class MediaPathError(OWLError):
+ """Raised when specified media path does not exist"""
+
+ def __init__(self, path: Path, message: str = None):
+ super().__init__(
+ message=None,
+ details={'path': str(path)}
+ )
+
+ message = (
+ self.format_error_header("Media Path Error") +
+ self.format_section(
+ "Problem",
+ f"Cannot access input path: {self.colorize(str(path), 'WHITE', bold=True)}"
+ ) +
+ self.format_section(
+ "Fix",
+ "1. Verify the path exists\n"
+ "2. Check file/directory permissions\n"
+ "3. Ensure the path is correctly specified in config or CLI args"
+ )
+ )
+ self.args = (message,)
+
+
+class InvalidMediaError(OWLError):
+ """Raised when input file is not a supported media format"""
+
+ def __init__(self, path: Path, valid_formats: set):
+ super().__init__(
+ message=None,
+ details={
+ 'path': str(path),
+ 'extension': path.suffix,
+ 'valid_formats': list(valid_formats)
+ }
+ )
+
+ message = (
+ self.format_error_header("Invalid Media Format") +
+ self.format_section(
+ "Problem",
+ f"File type not supported: {self.colorize(str(path), 'WHITE', bold=True)}"
+ ) +
+ self.format_section(
+ "Supported Formats",
+ "All media: " + ", ".join(f for f in valid_formats if f[0] == '.')
+ ) +
+ self.format_section(
+ "Fix",
+ "1. Provide a file in one of the supported formats\n"
+ "2. Convert your media to a supported format"
+ )
+ )
+ self.args = (message,)
+
+
+class MediaInitError(OWLError):
+ """Raised when media source fails to initialize"""
+
+ def __init__(self, path: Path, original_error: str):
+ super().__init__(
+ message=None,
+ details={
+ 'path': str(path),
+ 'error': original_error
+ }
+ )
+
+ message = (
+ self.format_error_header("Media Initialization Error") +
+ self.format_section(
+ "Problem",
+ f"Failed to initialize media from: {self.colorize(str(path), 'WHITE', bold=True)}\n"
+ f"Error: {original_error}"
+ ) +
+ self.format_section(
+ "Fix",
+ "1. Verify the file is not corrupted\n"
+ "2. Check if the file can be opened in other applications\n"
+ "3. Ensure sufficient system resources are available"
+ )
+ )
+ self.args = (message,)
+
+
+class CameraNotFoundError(OWLError):
+ """Raised when there are issues with camera initialization or connection."""
+
+ def __init__(self, error_type: str = None, original_error: str = None):
+ # Initialize parent first
+ super().__init__(
+ message=None,
+ details={
+ 'error_type': error_type,
+ 'original_error': original_error
+ }
+ )
+
+ message = (
+ self.format_error_header("Camera Connection Error") +
+ self.format_section(
+ "Problem",
+ f"Failed to initialize camera: {self.colorize(error_type, 'WHITE', bold=True)}\n"
+ f"Error details: {original_error}"
+ ) +
+ self.format_section(
+ "Solutions",
+ "1. Check if the camera ribbon cable is properly connected\n"
+ "2. Inspect the ribbon cable for damage\n"
+ "3. Verify camera module is properly seated\n"
+ "4. Check if camera is enabled in raspi-config\n"
+ "5. Try reconnecting the camera with the system powered off"
+ ) +
+ self.format_section(
+ "How to get more information",
+ f"• {self.colorize('vcgencmd get_camera', 'WHITE', bold=True)} - Check if camera is detected\n"
+ f"• {self.colorize('libcamera-hello', 'WHITE', bold=True)} - Test camera feed\n"
+ f"• {self.colorize('dmesg | grep -i camera', 'WHITE', bold=True)} - Check system logs"
+ )
+ )
+
+ self.args = (message,)
+
+
+class CameraInitError(OWLError):
+ """Raised when camera initialization fails"""
+
+ def __init__(self, error_msg: str):
+ super().__init__(
+ message=None,
+ details={'error': error_msg}
+ )
+
+ message = (
+ self.format_error_header("Camera Initialization Error") +
+ self.format_section(
+ "Problem",
+ f"Failed to initialize camera\nError: {error_msg}"
+ ) +
+ self.format_section(
+ "Fix",
+ "1. Check camera connection\n"
+ "2. Verify the camera is supported (currently only Raspberry Pi Cameras and USB Cameras are likely to work.\n"
+ "3. Ensure camera is not in use by another process\n"
+ "4. Try rebooting the system."
+ )
+ )
+ self.args = (message,)
\ No newline at end of file
diff --git a/utils/frame_reader.py b/utils/frame_reader.py
new file mode 100644
index 0000000..6e27290
--- /dev/null
+++ b/utils/frame_reader.py
@@ -0,0 +1,86 @@
+import time
+import os
+import cv2
+
+from imutils.video import FileVideoStream
+from utils.log_manager import LogManager
+
+class FrameReader:
+ def __init__(self, path, resolution=(640, 480), loop_time=5):
+ '''
+ FrameReader allows users to provide a directory of images, video or a single image to OWL for testing
+ and visualisation purposes.
+ :param path: path to the media (single image, directory of images or video)
+ :param loop_time: the delay between image display if using a directory)
+ '''
+
+ self.loop_time = loop_time
+ self.loop_start_time = time.time()
+ self.resolution = resolution
+ self.curr_image = None
+ self.files = None
+
+ self.logger = LogManager.get_logger(__name__)
+
+ if os.path.isdir(path):
+ self.files = iter(os.listdir(path))
+ self.path = path
+ self.cam = None
+ self.input_type = "directory"
+ self.single_image = False
+
+ elif os.path.isfile(path):
+ if path.endswith(('.png', '.jpg', '.jpeg')):
+ self.cam = cv2.resize(cv2.imread(path), self.resolution, interpolation=cv2.INTER_AREA)
+ self.input_type = "image"
+ self.single_image = True
+
+ else:
+ self.cam = FileVideoStream(path).start()
+ self.input_type = "video"
+ self.single_image = False
+ else:
+ self.logger.error("Path must be a directory or a file", exc_info=True)
+ raise ValueError(f'[ERROR] Invalid path to image/s: {path}')
+
+ def read(self):
+ if self.single_image:
+ return self.cam
+
+ elif self.files:
+ if self.curr_image is None or (time.time() - self.loop_start_time) > self.loop_time:
+ try:
+ image = next(self.files)
+ self.curr_image = cv2.imread(os.path.join(self.path, image))
+ self.curr_image = cv2.resize(self.curr_image, self.resolution, interpolation=cv2.INTER_AREA)
+
+ self.loop_start_time = time.time()
+
+ except StopIteration:
+ self.files = iter(os.listdir(self.path)) # restart from first image
+ return self.read()
+
+ return self.curr_image
+
+ else:
+ frame = self.cam.read()
+ frame = cv2.resize(frame, self.resolution, interpolation=cv2.INTER_AREA)
+
+ return frame
+
+ def reset(self):
+ if self.input_type == "directory":
+ # reset the iterator to the beginning of the directory
+ self.files = iter(os.listdir(self.path))
+ self.curr_image = None
+
+ elif self.input_type == "video":
+ # stop the current video stream and start a new one
+ self.cam.stop()
+ self.cam = FileVideoStream(self.path).start()
+
+ self.loop_start_time = time.time() # reset the loop timer
+
+ def stop(self):
+ if not self.single_image and self.cam:
+ self.cam.stop()
diff --git a/utils/greenonbrown.py b/utils/greenonbrown.py
new file mode 100644
index 0000000..f22ead4
--- /dev/null
+++ b/utils/greenonbrown.py
@@ -0,0 +1,88 @@
+#!/usr/bin/env python
+from utils.algorithms import exg, exg_standardised, exg_standardised_hue, hsv, exgr, gndvi, maxg
+import numpy as np
+import cv2
+
+
+class GreenOnBrown:
+ def __init__(self, algorithm='exg', label_file='models/labels.txt'):
+ self.algorithm = algorithm
+ self.kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
+
+ # Dictionary mapping algorithm names to functions
+ self.algorithms = {
+ 'exg': exg,
+ 'exgr': exgr,
+ 'maxg': maxg,
+ 'nexg': exg_standardised,
+ 'exhsv': exg_standardised_hue,
+ 'hsv': hsv,
+ 'gndvi': gndvi
+ }
+
+ def inference(self, image,
+ exg_min=30,
+ exg_max=250,
+ hue_min=30,
+ hue_max=90,
+ brightness_min=5,
+ brightness_max=200,
+ saturation_min=30,
+ saturation_max=255,
+ min_detection_area=1,
+ show_display=False,
+ algorithm='exg',
+ invert_hue=False,
+ label='WEED'):
+ threshed_already = False
+
+ # Retrieve the function based on the algorithm name
+ func = self.algorithms.get(algorithm, exg_standardised_hue)
+
+ # Handle special cases for functions with additional parameters
+ if algorithm == 'exhsv':
+ output = func(image, hue_min=hue_min, hue_max=hue_max, brightness_min=brightness_min,
+ brightness_max=brightness_max, saturation_min=saturation_min,
+ saturation_max=saturation_max, invert_hue=invert_hue)
+ elif algorithm == 'hsv':
+ output, threshed_already = func(image, hue_min=hue_min, hue_max=hue_max, brightness_min=brightness_min,
+ brightness_max=brightness_max, saturation_min=saturation_min,
+ saturation_max=saturation_max, invert_hue=invert_hue)
+ else:
+ output = func(image)
+
+ weed_centres = []
+ boxes = []
+
+ if not threshed_already:
+ output = np.clip(output, exg_min, exg_max)
+ output = np.uint8(np.abs(output))
+ if show_display:
+ cv2.imshow("HSV Threshold on ExG", output)
+ threshold_out = cv2.adaptiveThreshold(output, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV,
+ 31, 2)
+ # threshold_out = cv2.threshold(output, exg_min, exg_max, cv2.THRESH_BINARY)
+ threshold_out = cv2.morphologyEx(threshold_out, cv2.MORPH_CLOSE, self.kernel, iterations=1)
+ else:
+ threshold_out = cv2.morphologyEx(output, cv2.MORPH_CLOSE, self.kernel, iterations=5)
+
+ contours, _ = cv2.findContours(threshold_out, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
+
+ for c in contours:
+ if cv2.contourArea(c) > min_detection_area:
+ x, y, w, h = cv2.boundingRect(c)
+ boxes.append([x, y, w, h])
+ weed_centres.append([x + w // 2, y + h // 2])
+
+ if show_display:
+ image_out = image.copy()
+ for box in boxes:
+ startX, startY, boxW, boxH = box
+ endX = startX + boxW
+ endY = startY + boxH
+ cv2.putText(image_out, label, (startX, startY + 30), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (255, 0, 0), 2)
+ cv2.rectangle(image_out, (int(startX), int(startY)), (endX, endY), (0, 0, 255), 2)
+
+ return contours, boxes, weed_centres, image_out
+
+ return contours, boxes, weed_centres, image
diff --git a/greenongreen.py b/utils/greenongreen.py
similarity index 54%
rename from greenongreen.py
rename to utils/greenongreen.py
index 51dd750..ca3631a 100644
--- a/greenongreen.py
+++ b/utils/greenongreen.py
@@ -1,32 +1,47 @@
-#!/home/pi/.virtualenvs/owl/bin/python3
+#!/usr/bin/env python
from pycoral.adapters.common import input_size
from pycoral.adapters.detect import get_objects
from pycoral.utils.dataset import read_label_file
from pycoral.utils.edgetpu import make_interpreter
from pycoral.utils.edgetpu import run_inference
from pathlib import Path
-from glob import glob
+
import cv2
-import os
class GreenOnGreen:
- def __init__(self, algorithm='gog', label_file='models/labels.txt'):
- if algorithm == 'gog':
- self.algorithm_file = Path(glob('models/*.tflite')[0])
- print(f'[INFO] Using {self.algorithm_file.stem} model...')
+ def __init__(self, model_path='models', label_file='models/labels.txt'):
+ if model_path is None:
+ print('[WARNING] No model directory or path provided with --model-path flag. '
+ 'Attempting to load from default...')
+ model_path = 'models'
+ self.model_path = Path(model_path)
+
+ if self.model_path.is_dir():
+ model_files = list(self.model_path.glob('*.tflite'))
+ if not model_files:
+ raise FileNotFoundError('No .tflite model files found. Please provide a directory or .tflite file.')
+
+ else:
+ self.model_path = model_files[0]
+ print(f'[INFO] Using {self.model_path.stem} model...')
- elif algorithm.endswith('.tflite'):
- self.algorithm_file = Path(algorithm)
- print(f'[INFO] Using {self.algorithm_file.stem} model...')
+ elif self.model_path.suffix == '.tflite':
+ print(f'[INFO] Using {self.model_path.stem} model...')
else:
- print(f'[ERROR] Unknown algorithm {algorithm}, using default...')
- self.algorithm_file = Path(glob('models/*.tflite')[0])
- print(f'[INFO] Using {self.algorithm_file.stem} model...')
+ print(f'[WARNING] Specified model path {model_path} is unsupported, attempting to use default...')
+
+ model_files = Path('models').glob('*.tflite')
+ try:
+ self.model_path = next(model_files)
+ print(f'[INFO] Using {self.model_path.stem} model...')
+
+ except StopIteration:
+ print('[ERROR] No model files found.')
self.labels = read_label_file(label_file)
- self.interpreter = make_interpreter(self.algorithm_file.as_posix())
+ self.interpreter = make_interpreter(self.model_path.as_posix())
self.interpreter.allocate_tensors()
self.inference_size = input_size(self.interpreter)
self.objects = None
@@ -40,35 +55,34 @@ def inference(self, image, confidence=0.5, filter_id=0):
height, width, channels = image.shape
scale_x, scale_y = width / self.inference_size[0], height / self.inference_size[1]
- self.weedCenters = []
+ self.weed_centers = []
self.boxes = []
for det_object in self.objects:
if det_object.id == self.filter_id:
bbox = det_object.bbox.scale(scale_x, scale_y)
+
startX, startY = int(bbox.xmin), int(bbox.ymin)
endX, endY = int(bbox.xmax), int(bbox.ymax)
- boxW = startX - endX
- boxH = startY - endY
+ boxW = endX - startX
+ boxH = endY - startY
# save the bounding box
self.boxes.append([startX, startY, boxW, boxH])
# compute box center
centerX = int(startX + (boxW / 2))
centerY = int(startY + (boxH / 2))
- self.weedCenters.append([centerX, centerY])
+ self.weed_centers.append([centerX, centerY])
percent = int(100 * det_object.score)
- label = '{}% {}'.format(percent, self.labels.get(det_object.id, det_object.id))
-
-
+ label = f'{percent}% {self.labels.get(det_object.id, det_object.id)}'
cv2.rectangle(image, (startX, startY), (endX, endY), (0, 0, 255), 2)
cv2.putText(image, label, (startX, startY + 30),
cv2.FONT_HERSHEY_SIMPLEX, 1.0, (255, 0, 0), 2)
else:
pass
# print(self.weedCenters)
- return None, self.boxes, self.weedCenters, image
+ return None, self.boxes, self.weed_centers, image
diff --git a/utils/image_sampler.py b/utils/image_sampler.py
new file mode 100644
index 0000000..706bcf3
--- /dev/null
+++ b/utils/image_sampler.py
@@ -0,0 +1,137 @@
+import cv2
+import os
+import numpy as np
+
+from datetime import datetime
+from multiprocessing import Process, Queue
+from multiprocessing.queues import Empty
+from utils.log_manager import LogManager
+
+
+class ImageRecorder:
+ def __init__(self, save_directory, mode='whole', max_queue=200, new_process_threshold=90, max_processes=4):
+ self.save_directory = save_directory
+ self.mode = mode
+ self.queue = Queue(maxsize=max_queue)
+ self.new_process_threshold = new_process_threshold
+ self.max_processes = max_processes
+ self.processes = []
+ self.running = True
+ self.logger = LogManager.get_logger(__name__)
+
+ self.start_new_process()
+
+ def start_new_process(self):
+ if len(self.processes) < self.max_processes:
+ p = Process(target=self.save_images)
+ p.start()
+ self.processes.append(p)
+ self.logger.info(f"[INFO] Started new process, total processes: {len(self.processes)}")
+ else:
+ self.logger.warning("[INFO] Maximum number of processes reached.")
+
+ def save_images(self):
+ while self.running or not self.queue.empty():
+ try:
+ frame, frame_id, boxes, centres = self.queue.get(timeout=3)
+
+ except Empty:
+ if not self.running:
+ break
+ continue
+
+ except KeyboardInterrupt:
+ self.logger.info("[INFO] KeyboardInterrupt received in save_images. Exiting.")
+ break
+
+ # Process and save images based on mode
+ self.process_frame(frame, frame_id, boxes, centres)
+
+ def process_frame(self, frame, frame_id, boxes, centres):
+ timestamp = datetime.utcnow().strftime('%Y-%m-%dT%H%M%S.%f')[:-3] + 'Z'
+ if self.mode == 'whole':
+ self.save_frame(frame, frame_id, timestamp)
+ elif self.mode == 'bbox':
+ self.save_bboxes(frame, frame_id, boxes, timestamp)
+ elif self.mode == 'square':
+ self.save_squares(frame, frame_id, centres, timestamp)
+
+ def save_frame(self, frame, frame_id, timestamp):
+ filename = f"{timestamp}_frame_{frame_id}.png"
+ filepath = os.path.join(self.save_directory, filename)
+ cv2.imwrite(filepath, frame)
+
+ def save_bboxes(self, frame, frame_id, boxes, timestamp):
+ for contour_id, box in enumerate(boxes):
+ startX, startY, width, height = box
+ cropped_image = frame[startY:startY+height, startX:startX+width]
+ filename = f"{timestamp}_frame_{frame_id}_n_{str(contour_id)}.png"
+ filepath = os.path.join(self.save_directory, filename)
+ cv2.imwrite(filepath, cropped_image)
+
+ def save_squares(self, frame, frame_id, centres, timestamp):
+ side_length = min(200, frame.shape[0])
+ halfLength = side_length // 2
+ for contour_id, centre in enumerate(centres):
+ startX = max(centre[0] - np.random.randint(10, halfLength), 0)
+ startY = max(centre[1] - np.random.randint(10, halfLength), 0)
+ endX = startX + side_length
+ endY = startY + side_length
+ if endX > frame.shape[1]:
+ startX = frame.shape[1] - side_length
+ if endY > frame.shape[0]:
+ startY = frame.shape[0] - side_length
+ square_image = frame[startY:endY, startX:endX]
+ filename = f"{timestamp}_frame_{frame_id}_n_{str(contour_id)}.png"
+ filepath = os.path.join(self.save_directory, filename)
+ cv2.imwrite(filepath, square_image)
+
+ def add_frame(self, frame, frame_id, boxes, centres):
+ if not self.queue.full():
+ self.queue.put((frame, frame_id, boxes, centres))
+ else:
+ self.logger.info("[INFO] Queue is full, spinning up new process. Frame skipped.")
+
+ if self.queue.qsize() > self.new_process_threshold and len(self.processes) < self.max_processes:
+ self.start_new_process()
+
+ def stop(self):
+ """Stop image recording processes and clean up resources."""
+ self.running = False
+
+ try:
+ while not self.queue.empty():
+ self.queue.get_nowait()
+ except Exception as e:
+ self.logger.warning(f"Failed to clear queue: {e}")
+
+ self.queue.close()
+ self.queue.join_thread()
+
+ for p in self.processes:
+ try:
+ p.join(timeout=1)
+ if p.is_alive():
+ p.terminate()
+ p.join(timeout=0.5)
+ except Exception as e:
+ self.logger.error(f"Failed to stop process: {e}")
+
+ self.processes.clear()
+ self.logger.info("[INFO] ImageRecorder stopped.")
+
+ def terminate(self):
+ """Force terminate all image recording processes."""
+ self.running = False
+ for p in self.processes:
+ if p.is_alive():
+ try:
+ p.terminate()
+ p.join(timeout=0.5)
+ except Exception as e:
+ self.logger.error(f"Failed to terminate process: {e}")
+
+ self.processes.clear()
+ self.queue.close()
+ self.queue.join_thread()
+ self.logger.info("[INFO] All recording processes terminated forcefully.")
\ No newline at end of file
diff --git a/utils/input_manager.py b/utils/input_manager.py
new file mode 100644
index 0000000..c6cf120
--- /dev/null
+++ b/utils/input_manager.py
@@ -0,0 +1,249 @@
+import time
+import json
+import logging
+import configparser
+from multiprocessing import Value
+import paho.mqtt.client as mqtt
+
+# Set up logging.
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+###############################################################################
+# Dummy Interfaces (Replace these with your actual Owl and StatusIndicator implementations need help setting up)
+###############################################################################
+class DummyOwl:
+ def __init__(self):
+ self.sample_images = False
+ self.disable_detection = True
+ self.show_display = False
+ self.exg_min = 0
+ self.exg_max = 0
+ self.hue_min = 0
+ self.hue_max = 0
+ self.saturation_min = 0
+ self.saturation_max = 0
+ self.brightness_min = 0
+ self.brightness_max = 0
+ self.relay_controller = type("RelayController", (), {
+ "relay": type("Relay", (), {
+ "all_on": lambda: logger.info("Relays all on"),
+ "all_off": lambda: logger.info("Relays all off")
+ })()
+ })()
+ self.window_name = "DummyWindow"
+
+class DummyStatusIndicator:
+ def start_storage_indicator(self): pass
+ def enable_weed_detection(self): logger.info("Weed detection enabled")
+ def disable_weed_detection(self): logger.info("Weed detection disabled")
+ def enable_image_recording(self): logger.info("Image recording enabled")
+ def disable_image_recording(self): logger.info("Image recording disabled")
+ def generic_notification(self): logger.info("Generic notification")
+
+###############################################################################
+# Advanced Controller for a Slave
+###############################################################################
+class SimpleAdvancedController:
+ """
+ This controller updates advanced settings using MQTT commands.
+ It runs on a slave device with a unique slave_id.
+ """
+ def __init__(self, slave_id, low_config, high_config, owl, status, stop_flag):
+ self.slave_id = slave_id # Unique identifier for this slave (e.g. "0x201")
+ self.stop_flag = stop_flag
+
+ # Internal shared states.
+ self.recording_state = Value('b', False)
+ self.sensitivity_state = Value('b', False)
+ self.detection_mode_state = Value('i', 1) # "1" means Off
+
+ self.owl = owl
+ self.status_indicator = status
+
+ # Read sensitivity configuration from INI files.
+ self.low_sensitivity_settings = self._read_config(low_config)
+ self.high_sensitivity_settings = self._read_config(high_config)
+
+ def _read_config(self, config_file):
+ config = configparser.ConfigParser()
+ config.read(config_file)
+ return {
+ 'exg_min': config.getint('GreenOnBrown', 'exg_min'),
+ 'exg_max': config.getint('GreenOnBrown', 'exg_max'),
+ 'hue_min': config.getint('GreenOnBrown', 'hue_min'),
+ 'hue_max': config.getint('GreenOnBrown', 'hue_max'),
+ 'saturation_min': config.getint('GreenOnBrown', 'saturation_min'),
+ 'saturation_max': config.getint('GreenOnBrown', 'saturation_max'),
+ 'brightness_min': config.getint('GreenOnBrown', 'brightness_min'),
+ 'brightness_max': config.getint('GreenOnBrown', 'brightness_max')
+ }
+
+ def update_recording(self, state_str):
+ """Update recording state based on a command ('on' or 'off')."""
+ is_active = state_str.lower() == 'on'
+ with self.recording_state.get_lock():
+ self.recording_state.value = is_active
+ if is_active:
+ self.status_indicator.enable_image_recording()
+ self.owl.sample_images = True
+ else:
+ self.status_indicator.disable_image_recording()
+ self.owl.sample_images = False
+ logger.info(f"Slave {self.slave_id}: recording set to {is_active}")
+
+ def update_sensitivity(self, value):
+ """Update sensitivity settings based on a numeric value."""
+ try:
+ sensitivity_value = float(value)
+ except ValueError:
+ logger.error("Slave %s: Invalid sensitivity value", self.slave_id)
+ return
+
+ with self.sensitivity_state.get_lock():
+ self.sensitivity_state.value = sensitivity_value > 0
+ settings = self.low_sensitivity_settings if self.sensitivity_state.value else self.high_sensitivity_settings
+
+ # Update Owl instance settings.
+ self.owl.exg_min = settings['exg_min']
+ self.owl.exg_max = settings['exg_max']
+ self.owl.hue_min = settings['hue_min']
+ self.owl.hue_max = settings['hue_max']
+ self.owl.saturation_min = settings['saturation_min']
+ self.owl.saturation_max = settings['saturation_max']
+ self.owl.brightness_min = settings['brightness_min']
+ self.owl.brightness_max = settings['brightness_max']
+ logger.info(f"Slave {self.slave_id}: sensitivity set to {sensitivity_value}")
+
+ def update_detection_mode(self, mode):
+ """Update detection mode:
+ 0 = Detection on, 1 = Off, 2 = All solenoids on.
+ """
+ try:
+ mode = int(mode)
+ except ValueError:
+ logger.error("Slave %s: Invalid detection mode value", self.slave_id)
+ return
+
+ with self.detection_mode_state.get_lock():
+ self.detection_mode_state.value = mode
+
+ if mode == 0:
+ self.status_indicator.enable_weed_detection()
+ self.owl.disable_detection = False
+ elif mode == 2:
+ self.status_indicator.disable_weed_detection()
+ self.owl.relay_controller.relay.all_on()
+ self.owl.disable_detection = True
+ else:
+ self.status_indicator.disable_weed_detection()
+ self.owl.relay_controller.relay.all_off()
+ self.owl.disable_detection = True
+ logger.info(f"Slave {self.slave_id}: detection mode set to {mode}")
+
+ def run(self):
+ try:
+ while not self.stop_flag.value:
+ time.sleep(0.1)
+ except KeyboardInterrupt:
+ logger.info(f"Slave {self.slave_id}: KeyboardInterrupt received, exiting run loop.")
+ self.stop()
+
+ def stop(self):
+ with self.stop_flag.get_lock():
+ self.stop_flag.value = True
+
+###############################################################################
+# Slave MQTT Client: Runs on each slave to listen for commands addressed to it.
+###############################################################################
+class SlaveMqttSubscriber:
+ """
+ This MQTT subscriber runs on the slave. It subscribes to a topic and processes
+ only those messages that are intended for this slave, based on the "slave" field.
+ """
+ def __init__(self, broker, port, topic, slave_id, controller):
+ self.slave_id = slave_id # For example, "0x201"
+ self.topic = topic
+ self.controller = controller # Instance of SimpleAdvancedController for this slave
+ self.client = mqtt.Client("Slave_MQTT_Client_" + slave_id)
+ self.client.on_connect = self.on_connect
+ self.client.on_message = self.on_message
+
+ self.client.connect(broker, port, 60)
+ self.client.loop_start()
+
+ def on_connect(self, client, userdata, flags, rc):
+ logger.info(f"Slave {self.slave_id}: Connected to MQTT broker with code {rc}")
+ client.subscribe(self.topic)
+
+ def on_message(self, client, userdata, msg):
+ try:
+ payload = json.loads(msg.payload.decode('utf-8'))
+ msg_slave = payload.get("slave")
+ # Process the message only if it is intended for this slave.
+ if msg_slave != self.slave_id:
+ return
+
+ logger.info(f"Slave {self.slave_id}: Received message: {payload}")
+ command = payload.get("command")
+ if command == "recording":
+ state = payload.get("state")
+ if state is not None:
+ self.controller.update_recording(state)
+ elif command in ["sensitivity", "files"]:
+ value = payload.get("value")
+ if value is not None:
+ self.controller.update_sensitivity(value)
+ elif command == "detection_mode":
+ mode = payload.get("mode")
+ if mode is not None:
+ self.controller.update_detection_mode(mode)
+ else:
+ logger.warning(f"Slave {self.slave_id}: Unknown command received")
+ except Exception as e:
+ logger.error(f"Slave {self.slave_id}: Error processing MQTT message", exc_info=True)
+
+###############################################################################
+# Main: Set Up This Slave Device
+###############################################################################
+if __name__ == "__main__":
+ # This slave's unique identifier.
+ SLAVE_ID = "0x201" # Change as needed for each slave.
+
+ # Create a stop flag for orderly shutdown.
+ stop_flag = Value('b', False)
+
+ # Create your Owl and StatusIndicator instances.
+ owl = DummyOwl()
+ status = DummyStatusIndicator()
+
+ # Create the Advanced Controller instance for this slave.
+ controller = SimpleAdvancedController(
+ slave_id=SLAVE_ID,
+ low_config="low_config.ini", # Replace with your configuration file paths.
+ high_config="high_config.ini",
+ owl=owl,
+ status=status,
+ stop_flag=stop_flag
+ )
+ logger.info(f"Slave {SLAVE_ID} controller initialized.")
+
+ # Set up the MQTT subscriber on this slave.
+ subscriber = SlaveMqttSubscriber(
+ broker="localhost", # Replace with your MQTT broker address.
+ port=1883,
+ topic="commands/can",
+ slave_id=SLAVE_ID,
+ controller=controller
+ )
+
+ # Run the controller loop until interrupted.
+ try:
+ while not stop_flag.value:
+ time.sleep(1)
+ except KeyboardInterrupt:
+ logger.info(f"Slave {SLAVE_ID}: KeyboardInterrupt received; exiting.")
+ finally:
+ controller.stop()
+ subscriber.client.loop_stop()
+ subscriber.client.disconnect()
diff --git a/utils/log_manager.py b/utils/log_manager.py
new file mode 100644
index 0000000..c847e6f
--- /dev/null
+++ b/utils/log_manager.py
@@ -0,0 +1,162 @@
+import logging
+import json
+import queue
+import sys
+
+from pathlib import Path
+from queue import Queue
+from threading import Thread, Event
+from typing import Dict, Any
+from time import time
+from logging.handlers import RotatingFileHandler
+
+
+class JSONFormatter(logging.Formatter):
+ """Formats log records as JSON strings"""
+
+ def format(self, record: logging.LogRecord) -> str:
+ message = {
+ 'timestamp': self.formatTime(record, self.datefmt),
+ 'level': record.levelname,
+ 'logger': record.name,
+ 'message': record.getMessage(),
+ 'module': record.module,
+ 'function': record.funcName
+ }
+
+ # Add any extra context passed with the log
+ if hasattr(record, 'detection_data'):
+ message['detection_data'] = record.detection_data
+
+ return json.dumps(message)
+
+
+class ConsoleFormatter(logging.Formatter):
+ """Human-readable formatter for console output"""
+
+ def format(self, record: logging.LogRecord) -> str:
+ return f"{self.formatTime(record, self.datefmt)} - {record.levelname} - [{record.name}] - {record.getMessage()}"
+
+
+class LogManager:
+ """Centralized logging management for OWL"""
+ _instance = None
+ _initialized = False
+
+ BACKUP_COUNT = 100
+ MAX_BYTES = 10 * 1024 * 1024 # 10MB per file
+
+ def __new__(cls):
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ return cls._instance
+
+ def __init__(self):
+ if self._initialized:
+ return
+
+ self._initialized = True
+ self.detection_queue = Queue(maxsize=1000)
+ self.stop_event = Event()
+ self.batch_size = 100
+ self.flush_interval = 1.0 # seconds
+ self.last_flush = time()
+
+ # Define instance-wide loggers
+ self.logger = logging.getLogger("LogManager")
+ self.detection_logger = logging.getLogger("detection")
+
+ # Start the detection processing thread
+ self.worker = Thread(target=self._process_detection_queue, daemon=True)
+ self.worker.start()
+
+ @classmethod
+ def setup(cls, log_dir: Path, log_level: str = 'INFO') -> None:
+ """Initialize the logging system"""
+ instance = cls()
+
+ log_dir.mkdir(exist_ok=True)
+
+ root_logger = logging.getLogger()
+ root_logger.setLevel(log_level)
+ root_logger.handlers = []
+
+ console_handler = logging.StreamHandler(sys.stdout)
+ console_handler.setFormatter(ConsoleFormatter(
+ fmt='%(asctime)s - %(levelname)s - [%(name)s] - %(message)s',
+ datefmt='%Y-%m-%d %H:%M:%S'))
+ root_logger.addHandler(console_handler)
+
+ main_handler = RotatingFileHandler(
+ filename=log_dir / 'owl.jsonl',
+ maxBytes=cls.MAX_BYTES, # 10MB
+ backupCount=cls.BACKUP_COUNT)
+
+ main_handler.setFormatter(JSONFormatter())
+ root_logger.addHandler(main_handler)
+
+ detection_handler = RotatingFileHandler(
+ filename=log_dir / 'detections.jsonl',
+ maxBytes=cls.MAX_BYTES, # 10MB
+ backupCount=cls.BACKUP_COUNT)
+
+ detection_handler.setFormatter(JSONFormatter())
+
+ # Configure detection logger
+ detection_logger = logging.getLogger('detection')
+ detection_logger.handlers = [detection_handler]
+ detection_logger.propagate = False # Don't propagate to root logger
+
+ # Update the instance-level loggers
+ instance.logger = root_logger
+ instance.detection_logger = detection_logger
+
+ @classmethod
+ def get_logger(cls, name: str) -> logging.Logger:
+ """Get a logger instance for a module"""
+ return logging.getLogger(name)
+
+ def log_detection(self, frame_id: int, detections: Dict[str, Any]) -> None:
+ """Queue a detection event for logging"""
+ self.detection_queue.put({
+ 'timestamp': time(),
+ 'frame_id': frame_id,
+ 'detections': detections
+ })
+
+ def _process_detection_queue(self) -> None:
+ """Background worker to process detection events"""
+ batch = []
+
+ while not self.stop_event.is_set():
+ try:
+ event = self.detection_queue.get(timeout=0.1)
+ batch.append(event)
+
+ # Flush if batch is full or interval exceeded
+ if len(batch) >= self.batch_size or time() - self.last_flush >= self.flush_interval:
+ self._flush_detection_batch(batch)
+ batch.clear()
+ self.last_flush = time()
+
+ except queue.Empty:
+ pass # No event, continue loop
+
+ except Exception as e:
+ self.logger.error(f"Error in processing detection queue: {e}", exc_info=True)
+
+ if batch:
+ self._flush_detection_batch(batch)
+
+ def _flush_detection_batch(self, batch: list) -> None:
+ """Write batch of detection events to log"""
+ if batch:
+ self.detection_logger.info(
+ f"Processed batch of {len(batch)} detections",
+ extra={'detection_data': batch}
+ )
+
+ def stop(self) -> None:
+ """Stop the background worker"""
+ self.stop_event.set()
+ self.worker.join()
diff --git a/utils/output_manager.py b/utils/output_manager.py
new file mode 100644
index 0000000..e2021e1
--- /dev/null
+++ b/utils/output_manager.py
@@ -0,0 +1,585 @@
+from threading import Thread, Event, Condition, Lock
+from utils.vis_manager import RelayVis
+from utils.error_manager import OWLAlreadyRunningError
+from utils.log_manager import LogManager
+from enum import Enum
+from collections import deque
+from typing import Optional
+
+import subprocess
+import shutil
+import time
+import logging
+import platform
+
+logger = logging.getLogger(__name__)
+
+def get_platform_config() -> tuple[bool, Optional[Exception]]:
+ """Determine platform and return testing status and lgpio error type"""
+ system_platform = platform.platform().lower()
+ is_raspberry_pi = 'rpi' in system_platform or 'aarch' in system_platform
+
+ if is_raspberry_pi:
+ from gpiozero import Buzzer, OutputDevice, LED
+ import lgpio
+ return False, lgpio.error
+
+ is_windows = platform.system() == "Windows"
+ system_name = "Windows" if is_windows else "unrecognized"
+ logger.warning(
+ f"The system is running on a {system_name} platform. GPIO disabled. Test mode active."
+ )
+ return True, None
+
+testing, lgpioERROR = get_platform_config()
+
+# Import GPIO components only if needed
+if not testing:
+ from gpiozero import Buzzer, OutputDevice, LED
+
+# two test classes to run the analysis on a desktop computer if a "win32" platform is detected
+class TestRelay:
+ def __init__(self, relay_number, verbose=False):
+ self.relay_number = relay_number
+ self.verbose = verbose
+
+ def on(self):
+ if self.verbose:
+ print(f"[TEST] Relay {self.relay_number} ON")
+
+ def off(self):
+ if self.verbose:
+ print(f"[TEST] Relay {self.relay_number} OFF")
+
+class TestBuzzer:
+ def beep(self, on_time, off_time, n=1, verbose=False):
+ for i in range(n):
+ if verbose:
+ print('BEEP')
+
+class TestLED:
+ def __init__(self, pin):
+ self.pin = pin
+
+ def blink(self, on_time=0.1, off_time=0.1, n=1, verbose=False, background=True):
+ if n is None:
+ n = 1
+
+ for i in range(n):
+ if verbose:
+ print(f'BLINK {self.pin}')
+
+ def on(self):
+ print(f'LED {self.pin} ON')
+
+ def off(self):
+ print(f'LED {self.pin} OFF')
+
+
+class BaseStatusIndicator:
+ def __init__(self, save_directory, no_save=False):
+ self.logger = LogManager.get_logger(__name__)
+
+ self.save_directory = save_directory
+ self.no_save = no_save
+ self.testing = True if testing else False
+ self.storage_used = None
+ self.storage_total = None
+ self.update_event = Event()
+ self.running = True
+ self.thread = None
+ self.DRIVE_FULL = False
+
+ self.error_code = None
+ self.flashing_thread = None
+ self._set_led_trigger("ACT", "none")
+ self._set_led_trigger("PWR", "none")
+
+ def start_storage_indicator(self):
+ self.thread = Thread(target=self.run_update)
+ self.thread.start()
+
+ def run_update(self):
+ while self.running:
+ self.update()
+ self.update_event.wait(10.5)
+ self.update_event.clear()
+
+ def update(self):
+ if self.save_directory is not None:
+ self.storage_total, self.storage_used, _ = shutil.disk_usage(self.save_directory)
+ percent_full = (self.storage_used / self.storage_total)
+ self._update_storage_indicator(percent_full)
+
+ elif self.no_save:
+ pass
+
+ else:
+ self.error(6)
+
+ def error(self, error_code):
+ self.error_code = error_code
+ if self.flashing_thread is None or not self.flashing_thread.is_alive():
+ self.flashing_thread = Thread(target=self._flash_error_code)
+ self.flashing_thread.start()
+
+ def _flash_error_code(self):
+ while self.running:
+ for _ in range(self.error_code):
+ self._blink_leds()
+ time.sleep(0.2) # Interval between flashes
+ time.sleep(2) # Pause after each sequence
+
+ def _blink_leds(self):
+ self._set_led_state("ACT", 1)
+ self._set_led_state("PWR", 1)
+ time.sleep(0.2)
+ self._set_led_state("ACT", 0)
+ self._set_led_state("PWR", 0)
+
+ def _set_led_state(self, led, state):
+ if not self.testing:
+ LED_PATHS = {
+ "ACT": "/sys/class/leds/ACT/brightness",
+ "PWR": "/sys/class/leds/PWR/brightness"
+ }
+ try:
+ subprocess.run(
+ ['sudo', 'sh', '-c', f'echo {1 if state else 0} > {LED_PATHS[led]}'],
+ check=True
+ )
+ except subprocess.CalledProcessError as e:
+ self.logger.error(msg=f"Error: Could not set {led} LED. {e}", exc_info=True)
+
+ # Method to set LED trigger to 'none' to ensure manual control.
+ # Based on: https://howtoraspberrypi.com/controler-led-verte-raspberry-pi-2/
+ def _set_led_trigger(self, led, trigger):
+ if not self.testing:
+ LED_TRIGGER_PATHS = {
+ "ACT": "/sys/class/leds/ACT/trigger",
+ "PWR": "/sys/class/leds/PWR/trigger"
+ }
+ try:
+ subprocess.run(
+ ['sudo', 'sh', '-c', f'echo {trigger} > {LED_TRIGGER_PATHS[led]}'],
+ check=True
+ )
+ except subprocess.CalledProcessError as e:
+ self.logger.error(f"Error: Could not set {led} trigger to {trigger}.", exc_info=True)
+
+ def _update_storage_indicator(self, percent_full):
+ self.logger.warning("Called _update_storage_indicator() but it's not implemented.")
+ raise NotImplementedError("This method should be implemented by subclasses")
+
+ def stop(self):
+ """Stop all threads and ensure resources are cleaned up."""
+ self.running = False
+ self.update_event.set() # Wake up storage indicator thread
+
+ if self.thread and self.thread.is_alive():
+ self.thread.join(timeout=1) # Ensure thread stops
+
+ if self.flashing_thread and self.flashing_thread.is_alive():
+ self.flashing_thread.join(timeout=1) # Ensure flashing thread stops
+
+ self._cleanup_leds()
+ logger.info("[INFO] StatusIndicator stopped.")
+
+ def _cleanup_leds(self):
+ """Turn off LEDs and reset their states."""
+ try:
+ self._set_led_state("ACT", 0)
+ self._set_led_state("PWR", 0)
+ except Exception as e:
+ logger.error(f"Failed to clean up LEDs: {e}")
+
+
+class HeadlessStatusIndicator(BaseStatusIndicator):
+ def __init__(self, save_directory=None, no_save=False):
+ super().__init__(save_directory, no_save)
+
+ def _update_storage_indicator(self, percent_full):
+ if percent_full >= 0.90:
+ self.DRIVE_FULL = True
+
+
+class UteStatusIndicator(BaseStatusIndicator):
+ def __init__(self, save_directory, record_led_pin='BOARD38', storage_led_pin='BOARD40'):
+ super().__init__(save_directory)
+ LED_class = LED if not testing else TestLED
+ self.record_LED = LED_class(pin=record_led_pin)
+ self.storage_LED = LED_class(pin=storage_led_pin)
+
+ def _update_storage_indicator(self, percent_full):
+ if percent_full >= 0.90:
+ self.DRIVE_FULL = True
+ self.storage_LED.on()
+ self.record_LED.off()
+ elif percent_full >= 0.85:
+ self.storage_LED.blink(on_time=0.2, off_time=0.2, n=None, background=True)
+ elif percent_full >= 0.80:
+ self.storage_LED.blink(on_time=0.5, off_time=0.5, n=None, background=True)
+ elif percent_full >= 0.75:
+ self.storage_LED.blink(on_time=0.5, off_time=1.5, n=None, background=True)
+ elif percent_full >= 0.5:
+ self.storage_LED.blink(on_time=0.5, off_time=3.0, n=None, background=True)
+ else:
+ self.storage_LED.blink(on_time=0.5, off_time=4.5, n=None, background=True)
+
+ def setup_success(self):
+ self.storage_LED.blink(on_time=0.1, off_time=0.2, n=3)
+ self.record_LED.blink(on_time=0.1, off_time=0.2, n=3)
+
+ def image_write_indicator(self):
+ self.record_LED.blink(on_time=0.1, n=1, background=True)
+
+ def alert_flash(self):
+ self.storage_LED.blink(on_time=0.5, off_time=0.5, n=None, background=True)
+ self.record_LED.blink(on_time=0.5, off_time=0.5, n=None, background=True)
+
+ def error(self, error_code):
+ self.error_code = error_code
+ if self.flashing_thread is None or not self.flashing_thread.is_alive():
+ self.flashing_thread = Thread(target=self._flash_error_code)
+ self.flashing_thread.start()
+
+ def _flash_error_code(self):
+ while self.running:
+ for _ in range(self.error_code):
+ self._blink_leds()
+ self.storage_LED.blink(on_time=0.2, n=1, background=False) # Flash storage LED
+ self.record_LED.blink(on_time=0.2, n=1, background=False) # Flash record LED
+ time.sleep(0.2) # Interval between flashes
+ time.sleep(2) # Pause after each sequence
+
+ def stop(self):
+ super().stop()
+ if self.flashing_thread and self.flashing_thread.is_alive():
+ self.flashing_thread.join()
+ self.storage_LED.off()
+ self.record_LED.off()
+
+
+class AdvancedIndicatorState(Enum):
+ IDLE = 0
+ RECORDING = 1
+ DETECTING = 2
+ NOTIFICATION = 3
+ RECORDING_AND_DETECTING = 4
+ ERROR = 5
+
+
+class AdvancedStatusIndicator(BaseStatusIndicator):
+ def __init__(self, save_directory, status_led_pin='BOARD37'):
+ super().__init__(save_directory)
+ LED_class = LED if not testing else TestLED
+ self.led = LED_class(pin=status_led_pin)
+ self.state = AdvancedIndicatorState.IDLE
+ self.error_queue = deque()
+ self.state_lock = Lock()
+ self.weed_detection_enabled = False
+ self.image_recording_enabled = False
+ self.flashing_thread = None
+
+ def _update_storage_indicator(self, percent_full):
+ if percent_full >= 0.90:
+ self.DRIVE_FULL = True
+ self.error(1) # Use error code 1 for drive full
+
+ def setup_success(self):
+ self.led.blink(on_time=0.1, off_time=0.1, n=2)
+
+ def _update_state(self):
+ if self.state != AdvancedIndicatorState.ERROR:
+ if self.weed_detection_enabled and self.image_recording_enabled:
+ self.state = AdvancedIndicatorState.RECORDING_AND_DETECTING
+ elif self.weed_detection_enabled:
+ self.state = AdvancedIndicatorState.DETECTING
+ elif self.image_recording_enabled:
+ self.state = AdvancedIndicatorState.RECORDING
+ else:
+ self.state = AdvancedIndicatorState.IDLE
+
+ def enable_weed_detection(self):
+ with self.state_lock:
+ self.weed_detection_enabled = True
+ self._update_state()
+
+ def disable_weed_detection(self):
+ with self.state_lock:
+ self.weed_detection_enabled = False
+ self._update_state()
+
+ def enable_image_recording(self):
+ with self.state_lock:
+ self.image_recording_enabled = True
+ self._update_state()
+
+ def disable_image_recording(self):
+ with self.state_lock:
+ self.image_recording_enabled = False
+ self._update_state()
+
+ def image_write_indicator(self):
+ with self.state_lock:
+ if self.state not in [AdvancedIndicatorState.ERROR, AdvancedIndicatorState.DETECTING,
+ AdvancedIndicatorState.RECORDING_AND_DETECTING]:
+ try:
+ self.led.blink(on_time=0.1, off_time=0.1, n=1, background=True)
+ except KeyboardInterrupt:
+ logger.info("[INFO] KeyboardInterrupt received during image_write_indicator. Turning off LED.")
+ self.led.off()
+ raise
+ except Exception as e:
+ logger.error(f"Error in image_write_indicator: {e}", exc_info=True)
+
+ def weed_detect_indicator(self):
+ with self.state_lock:
+ if self.state in [AdvancedIndicatorState.DETECTING, AdvancedIndicatorState.RECORDING_AND_DETECTING]:
+ try:
+ self.led.blink(on_time=0.05, off_time=0.05, n=1, background=True)
+ except KeyboardInterrupt:
+ logger.info("[INFO] KeyboardInterrupt received during weed_detect_indicator. Turning off LED.")
+ self.led.off()
+ raise
+ except Exception as e:
+ logger.error(f"Error in weed_detect_indicator: {e}", exc_info=True)
+
+ def generic_notification(self):
+ try:
+ with self.state_lock:
+ init_state = self.state
+ self.state = AdvancedIndicatorState.NOTIFICATION
+ self.led.off() # Reset LED state before notification
+
+ self.led.blink(on_time=0.1, off_time=0.1, n=2, background=False)
+ self.state = init_state
+ except KeyboardInterrupt:
+ logger.info("[INFO] KeyboardInterrupt received during generic_notification. Turning off LED.")
+ self.led.off()
+ raise
+ except Exception as e:
+ logger.error(f"Error in generic_notification: {e}", exc_info=True)
+
+ def error(self, error_code):
+ self.error_code = error_code
+ with self.state_lock:
+ self.state = AdvancedIndicatorState.ERROR
+ if self.flashing_thread is None or not self.flashing_thread.is_alive():
+ self.flashing_thread = Thread(target=self._flash_error_code)
+ self.flashing_thread.start()
+
+ def _flash_error_code(self):
+ try:
+ while self.running:
+ for _ in range(self.error_code):
+ self._blink_leds()
+ time.sleep(0.2)
+ time.sleep(2)
+ except KeyboardInterrupt:
+ logger.info("[INFO] KeyboardInterrupt received in _flash_error_code. Exiting.")
+ except Exception as e:
+ logger.error(f"Error in _flash_error_code: {e}", exc_info=True)
+ finally:
+ self._cleanup_leds()
+
+ def stop(self):
+ super().stop()
+ if self.flashing_thread and self.flashing_thread.is_alive():
+ self.flashing_thread.join()
+ self.led.off()
+
+
+# control class for the relay board
+class RelayControl:
+ def __init__(self, relay_dict):
+ self.logger = LogManager.get_logger(__name__)
+
+ self.testing = True if testing else False
+ self.relay_dict = relay_dict
+ self.on = False
+
+ # used to toggle activation of GPIO pins for LEDs
+ self.field_data_recording = False
+
+ if not self.testing:
+ try:
+ self.buzzer = Buzzer(pin='BOARD7')
+
+ except Exception as e:
+ if isinstance(e, lgpioERROR) and 'GPIO busy' in str(e):
+ raise OWLAlreadyRunningError("OWL instance may already be running.") from e
+ else:
+ raise
+
+ for relay, board_pin in self.relay_dict.items():
+ self.relay_dict[relay] = OutputDevice(pin=f'BOARD{board_pin}')
+
+ else:
+ self.buzzer = TestBuzzer()
+ for relay, board_pin in self.relay_dict.items():
+ self.relay_dict[relay] = TestRelay(board_pin)
+
+ def relay_on(self, relay_number, verbose=True):
+ relay = self.relay_dict[relay_number]
+ relay.on()
+
+ if verbose:
+ print(f"Relay {relay_number} ON")
+
+ def relay_off(self, relay_number, verbose=True):
+ relay = self.relay_dict[relay_number]
+ relay.off()
+
+ if verbose:
+ print(f"Relay {relay_number} OFF")
+
+ def beep(self, duration=0.2, repeats=2):
+ self.buzzer.beep(on_time=duration, off_time=(duration / 2), n=repeats)
+
+ def all_on(self, verbose=False):
+ for relay in self.relay_dict.keys():
+ self.relay_on(relay, verbose=verbose)
+
+ def all_off(self, verbose=False):
+ for relay in self.relay_dict.keys():
+ self.relay_off(relay, verbose=verbose)
+
+ def remove(self, relay_number):
+ self.relay_dict.pop(relay_number, None)
+
+ def clear(self):
+ self.relay_dict = {}
+
+ def stop(self):
+ self.clear()
+ self.all_off()
+
+# this class does the hard work of receiving detection 'jobs' and queuing them to be actuated. It only turns a nozzle on
+# if the sprayDur has not elapsed or if the nozzle isn't already on.
+class RelayController:
+ def __init__(self, relay_dict, vis=False, status_led=None):
+ self.logger = LogManager.get_logger(__name__)
+
+ self.relay_dict = relay_dict
+ self.vis = vis
+ self.status_led = status_led
+ # instantiate relay control with supplied relay dictionary to map to correct board pins
+ try:
+ self.relay = RelayControl(self.relay_dict)
+ except OWLAlreadyRunningError:
+ self.logger.error("Failed to initialize RelayControl: OWL is already running and using GPIO pin 7.")
+ raise
+ self.relay_queue_dict = {}
+ self.relay_condition_dict = {}
+
+ # create a job queue and Condition() for each nozzle
+ self.logger.info("[INFO] Setting up nozzles...")
+ self.relay_vis = RelayVis(relays=len(self.relay_dict.keys()))
+ for relay_number in range(0, len(self.relay_dict)):
+ self.relay_queue_dict[relay_number] = deque(maxlen=5)
+ self.relay_condition_dict[relay_number] = Condition()
+
+ # create the consumer threads, setDaemon and start the threads.
+ relay_thread = Thread(target=self.consumer, args=[relay_number])
+ relay_thread.setDaemon(True)
+ relay_thread.start()
+
+ time.sleep(1)
+ self.logger.info("[INFO] Nozzle setup complete. Initiating camera...")
+ self.relay.beep(duration=0.5)
+
+ def receive(self, relay, time_stamp, location=0, delay=0, duration=1):
+ """
+ this method adds a new job to specified relay queue. GPS location data etc to be added. Time stamped
+ records the true time of weed detection from main thread, which is compared to time of relay activation for accurate
+ on durations. There will be a minimum on duration of this processing speed ~ 0.3s. Will default to 0 though.
+ :param relay: relay id (zero based)
+ :param time_stamp: this is the time of detection
+ :param location: GPS functionality to be added here
+ :param delay: on delay to be added in the future
+ :param duration: duration of spray
+ """
+ input_queue_message = [relay, time_stamp, delay, duration]
+ input_queue = self.relay_queue_dict[relay]
+ input_condition = self.relay_condition_dict[relay]
+ # notifies the consumer thread when something has been added to the queue
+ with input_condition:
+ input_queue.append(input_queue_message)
+ input_condition.notify()
+
+ def consumer(self, relay):
+ """
+ Takes only one parameter - nozzle, which enables the selection of the deque, condition from the dictionaries.
+ The consumer method is threaded for each nozzle and will wait until it is notified that a new job has been added
+ from the receive method. It will then compare the time of detection with time of spraying to activate that nozzle
+ for required length of time.
+ :param relay: relay id number
+ """
+ self.running = True
+ input_condition = self.relay_condition_dict[relay]
+ input_condition.acquire()
+ relay_on = False
+ relay_queue = self.relay_queue_dict[relay]
+
+ while self.running:
+ while relay_queue:
+ job = relay_queue.popleft()
+ input_condition.release()
+ # check to make sure time is positive
+ onDur = 0 if (job[3] - (time.time() - job[1])) <= 0 else (job[3] - (time.time() - job[1]))
+
+ if not relay_on:
+ time.sleep(job[2]) # add in the delay variable
+ self.relay.relay_on(relay, verbose=False)
+ if self.status_led:
+ self.status_led.blink(on_time=0.1, n=1, background=True)
+
+ if self.vis:
+ self.relay_vis.update(relay=relay, status=True)
+
+ relay_on = True
+
+ try:
+ time.sleep(onDur)
+
+ except ValueError:
+ time.sleep(0)
+
+ input_condition.acquire()
+
+ if len(relay_queue) == 0:
+ self.relay.relay_off(relay, verbose=False)
+
+ if self.vis:
+ self.relay_vis.update(relay=relay, status=False)
+ relay_on = False
+
+ input_condition.wait()
+
+ def stop(self):
+ self.running = False
+
+
+if __name__ == "__main__":
+ print("Starting test of status indicators...")
+
+ # Test HeadlessStatusIndicator
+ print("\nTesting HeadlessStatusIndicator...")
+ headless_indicator = HeadlessStatusIndicator(save_directory="output")
+ headless_indicator.show_error(3) # Show an error with 3 flashes
+ headless_indicator.stop()
+
+ # Test UteStatusIndicator
+ print("\nTesting UteStatusIndicator...")
+ ute_indicator = UteStatusIndicator(save_directory="output", record_led_pin='BOARD38', storage_led_pin='BOARD40')
+ ute_indicator.show_error(4) # Show an error with 4 flashes
+ ute_indicator.stop()
+
+ # Test AdvancedStatusIndicator
+ print("\nTesting AdvancedStatusIndicator...")
+ advanced_indicator = AdvancedStatusIndicator(save_directory="output", status_led_pin='BOARD37')
+ advanced_indicator.show_error(2) # Show an error with 2 flashes
+ advanced_indicator.stop()
+
+ print("\nTest complete.")
\ No newline at end of file
diff --git a/utils/video_manager.py b/utils/video_manager.py
new file mode 100644
index 0000000..acecd3f
--- /dev/null
+++ b/utils/video_manager.py
@@ -0,0 +1,327 @@
+import cv2
+import time
+
+from threading import Thread, Event, Condition, Lock
+from utils.log_manager import LogManager
+
+# determine availability of picamera versions
+try:
+ from picamera.array import PiRGBArray
+ from picamera import PiCamera
+ PICAMERA_VERSION = 'legacy'
+
+except Exception as e:
+ PICAMERA_VERSION = None
+
+try:
+ from picamera2 import Picamera2
+ from libcamera import Transform
+ import libcamera
+ PICAMERA_VERSION = 'picamera2'
+
+except Exception as e:
+ PICAMERA_VERSION = None
+
+# class to support webcams
+class WebcamStream:
+ def __init__(self, src=0):
+ self.logger = LogManager.get_logger(__name__)
+ self.name = "WebcamStream"
+ self.logger.info(f'Camera type: {self.name}')
+ self.stream = cv2.VideoCapture(src)
+
+ self.frame_width = self.stream.get(cv2.CAP_PROP_FRAME_WIDTH)
+ self.frame_height = self.stream.get(cv2.CAP_PROP_FRAME_HEIGHT)
+
+ # Check if the stream opened successfully
+ if not self.stream.isOpened():
+ self.stream.release()
+ self.logger.error(f'Unable to open video source: {src}')
+ raise ValueError("Unable to open video source:", src)
+
+ # read the first frame from the stream
+ self.grabbed, self.frame = self.stream.read()
+ if not self.grabbed:
+ self.stream.release()
+ self.logger.error(f'Unable to read from video source: {src}')
+ raise ValueError("Unable to read from video source:", src)
+
+ # initialize the thread name, stop event, and the thread itself
+ self.stop_event = Event()
+ self.thread = Thread(target=self.update, name=self.name, args=())
+ self.thread.daemon = True
+
+ def start(self):
+ self.thread.start()
+ return self
+
+ def update(self):
+ # keep looping infinitely until the thread is stopped
+ try:
+ while not self.stop_event.is_set():
+ # Read the next frame from the stream
+ self.grabbed, self.frame = self.stream.read()
+
+ # If not grabbed, end of the stream has been reached.
+ if not self.grabbed:
+ self.stop_event.set() # Ensure the loop stops if no frame is grabbed
+ except Exception as e:
+ self.logger.error(f"Exception in WebcamStream update loop: {e}", exc_info=True)
+ finally:
+ # Clean up resources after loop is done
+ self.stream.release()
+
+ def read(self):
+ # return the frame most recently read
+ return self.frame
+
+ def stop(self):
+ self.stop_event.set()
+ self.thread.join()
+
+
+class PiCamera2Stream:
+ def __init__(self, src=0, resolution=(416, 320), exp_compensation=-2, **kwargs):
+ self.logger = LogManager.get_logger(__name__)
+ self.name = 'Picamera2Stream'
+ self.logger.info(f'Camera type: {self.name}')
+ self.size = resolution # picamera2 uses size instead of resolution, keeping this consistent
+ self.frame_width = None
+ self.frame_height = None
+ self.frame = None
+ self.frame_available = False
+
+ self.stopped = Event()
+ self.condition = Condition()
+ self.lock = Lock()
+
+ # set the picamera2 config and controls. Refer to picamera2 documentation for full explanations:
+
+ self.configurations = {
+ # for those checking closely, using RGB888 may seem incorrect, however libcamera means a BGR format. Check
+ # https://github.com/raspberrypi/picamera2/issues/848 for full explanation.
+ "format": 'RGB888',
+ "size": self.size
+ }
+
+ self.controls = {
+ "AeExposureMode": 1,
+ "AwbMode": libcamera.controls.AwbModeEnum.Daylight,
+ "ExposureValue": exp_compensation
+ }
+ # Or if you prefer split logs for different aspects:
+ self.logger.info("Setting camera format", extra=dict(
+ format='RGB888',
+ image_size=list(self.size),
+ note='RGB888 represents BGR format in libcamera'))
+
+ self.logger.info("Setting camera controls", extra=dict(
+ exposure_mode=1,
+ awb_mode='Daylight',
+ exposure_value=exp_compensation))
+
+ # Update config with any additional/overridden parameters
+ self.controls.update(kwargs)
+
+ # Initialize the camera
+ self.camera = Picamera2(src)
+ self.camera_model = self.camera.camera_properties['Model']
+
+ if self.camera_model == 'imx296':
+ self.logger.info('[INFO] Using IMX296 Global Shutter Camera')
+
+ elif self.camera_model == 'imx477':
+ self.logger.info('[INFO] Using IMX477 HQ Camera')
+
+ elif self.camera_model == 'imx708':
+ self.logger.info('[INFO] Using Raspberry Pi Camera Module 3. Setting focal point at 1.2 m...')
+ self.controls['AfMode'] = libcamera.controls.AfModeEnum.Manual
+ self.controls['LensPosition'] = 1.2
+
+ else:
+ self.logger.info('[INFO] Unrecognised camera module, continuing with default settings.')
+
+ try:
+ self.config = self.camera.create_preview_configuration(main=self.configurations,
+ transform=Transform(hflip=True, vflip=True),
+ queue=False,
+ controls=self.controls)
+ self.camera.configure(self.config)
+ self.camera.start()
+
+ # set dimensions directly from the video feed
+ self.frame_width = self.camera.camera_configuration()['main']['size'][0]
+ self.frame_height = self.camera.camera_configuration()['main']['size'][1]
+
+ # allow the camera time to warm up
+ time.sleep(2)
+
+ except Exception as e:
+ self.logger.error(f"Failed to initialize PiCamera2: {e}", exc_info=True)
+ raise
+
+ if self.frame_width != resolution[0] or self.frame_height != resolution[1]:
+ message = (f"The actual frame size ({self.frame_width}x{self.frame_height}) "
+ f"differs from the expected resolution ({resolution[0]}x{resolution[1]}).")
+ self.logger.warning(message)
+
+ def start(self):
+ # Start the thread to update frames
+ self.thread = Thread(target=self.update, name=self.name, args=())
+ self.thread.daemon = True
+ self.thread.start()
+ return self
+
+ def update(self):
+ try:
+ while not self.stopped.is_set():
+ frame = self.camera.capture_array("main")
+ if frame is not None:
+ with self.lock:
+ self.frame = frame
+ self.frame_available = True
+
+ with self.condition:
+ self.condition.notify_all()
+
+ except Exception as e:
+ self.logger.error(f"Exception in PiCamera2Stream update loop: {e}", exc_info=True)
+ finally:
+ self.camera.stop() # Ensure camera resources are released properly
+
+ def read(self):
+ # return the frame most recently read
+ with self.condition:
+ while not self.frame_available:
+ self.condition.wait()
+
+ while self.lock:
+ self.frame_available = False
+ return self.frame
+
+ def stop(self):
+ self.stopped.set()
+ self.thread.join()
+ self.camera.stop()
+ time.sleep(2) # Allow time for the camera to be released properly
+
+
+class PiCameraStream:
+ def __init__(self, resolution=(416, 320), exp_compensation=-2, **kwargs):
+ self.logger = LogManager.get_logger(__name__)
+ self.name = 'PicameraStream'
+ self.logger.info(f'Camera type: {self.name}')
+ self.frame_width = None
+ self.frame_height = None
+
+ try:
+ self.camera = PiCamera()
+
+ self.camera.resolution = resolution
+ self.camera.exposure_mode = 'beach'
+ self.camera.awb_mode = 'auto'
+ self.camera.sensor_mode = 0
+ self.camera.exposure_compensation = exp_compensation
+
+ self.frame_width = self.camera.resolution[0]
+ self.frame_height = self.camera.resolution[1]
+
+ if self.frame_width != resolution[0] or self.frame_height != resolution[1]:
+ message = (f"The actual frame size ({self.frame_width}x{self.frame_height}) "
+ f"differs from the expected resolution ({resolution[0]}x{resolution[1]}).")
+ self.logger.warning(message)
+
+ # Set optional camera parameters (refer to PiCamera docs)
+ for (arg, value) in kwargs.items():
+ setattr(self.camera, arg, value)
+
+ # Initialize the stream
+ self.rawCapture = PiRGBArray(self.camera, size=resolution)
+ self.stream = self.camera.capture_continuous(self.rawCapture,
+ format="bgr",
+ use_video_port=True)
+
+ except Exception as e:
+ self.logger.error(f"Failed to initialize PiCamera: {e}", exc_info=True)
+ raise
+
+ self.frame = None
+ self.stopped = Event()
+ self.thread = Thread(target=self.update, name=self.name, args=())
+ self.thread.daemon = True # Thread will close when main program exits
+
+ def start(self):
+ # Start the thread to read frames from the video stream
+ self.thread.start()
+ return self
+
+ def update(self):
+ try:
+ for f in self.stream:
+ self.frame = f.array
+ self.rawCapture.truncate(0)
+
+ if self.stopped.is_set():
+ break
+ except Exception as e:
+ self.logger.error(f"Exception in PiCameraStream update loop: {e}", exc_info=True)
+
+ finally:
+ self.stream.close()
+ self.rawCapture.close()
+ self.camera.close()
+
+ def read(self):
+ # return the frame most recently read
+ return self.frame
+
+ def stop(self):
+ # Signal the thread to stop
+ self.stopped.set()
+
+ # Wait for the thread to finish
+ self.thread.join()
+
+
+# overarching class to determine which stream to use
+class VideoStream:
+ def __init__(self, src=0, resolution=(416, 320), exp_compensation=-2, **kwargs):
+ self.CAMERA_VERSION = PICAMERA_VERSION if PICAMERA_VERSION is not None else 'webcam'
+ self.logger = LogManager.get_logger(__name__)
+ self.frame_height = None
+ self.frame_width = None
+
+ if self.CAMERA_VERSION == 'legacy':
+ self.stream = PiCameraStream(resolution=resolution, exp_compensation=exp_compensation, **kwargs)
+
+ elif self.CAMERA_VERSION == 'picamera2':
+ self.stream = PiCamera2Stream(src=src, resolution=resolution, exp_compensation=exp_compensation, **kwargs)
+
+ elif self.CAMERA_VERSION == 'webcam':
+ self.stream = WebcamStream(src=src)
+
+ else:
+ self.logger.error(f"Unsupported camera version: {self.CAMERA_VERSION}")
+ raise ValueError(f"Unsupported camera version: {self.CAMERA_VERSION}")
+
+ # set the image dimensions directly from the frame streamed
+ self.frame_width = self.stream.frame_width
+ self.frame_height = self.stream.frame_height
+
+ def start(self):
+ # start the threaded video stream
+ return self.stream.start()
+
+ def update(self):
+ # grab the next frame from the stream
+ self.stream.update()
+
+ def read(self):
+ # return the current frame
+ return self.stream.read()
+
+ def stop(self):
+ # stop the thread and release any resources
+ self.stream.stop()
+
+
diff --git a/utils/cli_vis.py b/utils/vis_manager.py
similarity index 65%
rename from utils/cli_vis.py
rename to utils/vis_manager.py
index 0755dc0..b7747ad 100644
--- a/utils/cli_vis.py
+++ b/utils/vis_manager.py
@@ -1,8 +1,37 @@
import time
-from blessed import Terminal
import numpy as np
+import warnings
-class NozzleVis:
+class BasicTerminal:
+ def __init__(self):
+ self.width = 80
+ self.height = 24
+ self.class_name = "BasicTerminal"
+
+ @staticmethod
+ def move_x(x):
+ return f"\033[{x}G"
+
+ @property
+ def normal(self):
+ return "\033[0m"
+
+ def on_color_rgb(self, r, g, b):
+ return f"\033[48;2;{r};{g};{b}m"
+
+ def __str__(self):
+ return f"<{self.class_name} object with width={self.width} and height={self.height}>"
+
+try:
+ from blessed import Terminal
+
+except ModuleNotFoundError:
+ warnings.warn("[WARNING] blessed library not found. Using basic terminal functionality. "
+ "\nNote, please run 'pip install blessed' and check OWL installation to fix.")
+ Terminal = BasicTerminal
+
+
+class RelayVis:
def __init__(self, relays=4):
self.term = Terminal()
self.relays = relays
@@ -17,7 +46,7 @@ def __init__(self, relays=4):
def setup(self):
for id, pos in enumerate(self.x_positions):
- print(self.term.move_x(pos), f'Nozzle {id}', end=' ')
+ print(self.term.move_x(pos), f'Nozzle {id + 1}', end=' ')
print('\r')
for i, x_pos in enumerate(self.x_positions):
r, g, b = self.inactive_color
@@ -40,33 +69,10 @@ def close(self):
print("\n", end='\n')
if __name__ == "__main__":
- box_drawer = NozzleVis(relays=4)
- #
+ box_drawer = RelayVis(relays=4)
+
for i in range(0, 100):
relay = np.random.randint(0, 4)
status = bool(np.random.randint(0, 2))
box_drawer.update(relay=relay, status=status)
time.sleep(0.01)
-
-
- # sys.exit()
- # from blessed import Terminal
- #
- # term = Terminal()
- #
- # with term.fullscreen():
- # with term.cbreak():
- # # Set the position and size of the box
- # x_position = 10
- # y_position = 5
- # box_width = 10
- # box_height = 10
- #
- # # Draw the box
- # # term.move_xy(x_position, y_position)
- # box_str = term.on_color_rgb(0, 255, 0) + " " * box_width + term.normal
- #
- # print(f"{box_str}\n", end="")
-
- # Wait for a key press before exiting
- # term.inkey()
\ No newline at end of file
diff --git a/version.py b/version.py
new file mode 100644
index 0000000..053c408
--- /dev/null
+++ b/version.py
@@ -0,0 +1,63 @@
+import logging
+from dataclasses import dataclass
+import platform
+import sys
+import subprocess
+from typing import Optional
+
+
+@dataclass
+class Version:
+ major: int = 2
+ minor: int = 2
+ patch: int = 0
+ tag: Optional[str] = None
+
+ def __str__(self) -> str:
+ return f"{self.major}.{self.minor}.{self.patch}" + (f"-{self.tag}" if self.tag else "")
+
+VERSION = Version()
+
+class SystemInfo:
+ logger = logging.getLogger("SystemInfo")
+
+ @staticmethod
+ def get_os_info() -> dict:
+ return {
+ 'system': platform.system(),
+ 'release': platform.release(),
+ 'version': platform.version(),
+ 'machine': platform.machine(),
+ 'processor': platform.processor()
+ }
+
+ @staticmethod
+ def get_python_info() -> dict:
+ return {
+ 'version': sys.version,
+ 'implementation': platform.python_implementation(),
+ 'compiler': platform.python_compiler()
+ }
+
+ @staticmethod
+ def get_rpi_info() -> Optional[str]:
+ try:
+ with open('/proc/device-tree/model', 'r') as f:
+ return f.read().strip('\x00')
+ except FileNotFoundError:
+ SystemInfo.logger.warning("Raspberry Pi information not found.")
+ return None
+
+ @staticmethod
+ def get_git_info() -> Optional[dict]:
+ try:
+ # Check if git is available first
+ if subprocess.call(['which', 'git'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) != 0:
+ raise FileNotFoundError("Git not available")
+
+ commit = subprocess.check_output(['git', 'rev-parse', '--short', 'HEAD']).decode('ascii').strip()
+ branch = subprocess.check_output(['git', 'rev-parse', '--abbrev-ref', 'HEAD']).decode('ascii').strip()
+ return {'commit': commit, 'branch': branch}
+ except (subprocess.CalledProcessError, FileNotFoundError) as e:
+ SystemInfo.logger.warning("Git information could not be retrieved: %s", e)
+ return None
diff --git a/video_analysis.py b/video_analysis.py
deleted file mode 100644
index 30093b4..0000000
--- a/video_analysis.py
+++ /dev/null
@@ -1,474 +0,0 @@
-from tqdm import tqdm
-from datetime import datetime, timezone
-from greenonbrown import green_on_brown
-from image_sampler import bounding_box_image_sample, whole_image_save
-from imutils.video import count_frames, FileVideoStream
-from threading import Thread
-import pandas as pd
-import numpy as np
-import imutils
-import time
-import glob
-import cv2
-import csv
-import os
-
-
-def four_frame_analysis(exgFile: str, exgsFile: str, hueFile: str, exhuFile: str, HDFile: str):
- baseName = os.path.splitext(os.path.basename(exhuFile))[0]
-
- exgVideo = cv2.VideoCapture(exgFile)
- print("[INFO] Loaded {}".format(exgFile))
- lenexg = count_frames(exgFile, override=True) - 1
-
- exgsVideo = cv2.VideoCapture(exgsFile)
- print("[INFO] Loaded {}".format(exgsFile))
- lenexgs = count_frames(exgsFile, override=True) - 1
-
- hueVideo = cv2.VideoCapture(hueFile)
- print("[INFO] Loaded {}".format(hueFile))
- lenhue = count_frames(hueFile, override=True) - 1
-
- exhuVideo = cv2.VideoCapture(exhuFile)
- print("[INFO] Loaded {}".format(exhuFile))
- lenexhu = count_frames(exhuFile, override=True) - 1
-
- videoHD = cv2.VideoCapture(HDFile)
- print("[INFO] Loaded {}".format(HDFile))
- lenHD = count_frames(HDFile, override=True) - 1
-
- hdFrame = None
- exgFrame = None
- exgsFrame = None
- hueFrame = None
- exhuFrame = None
-
- hdframecount = 0
- exgframecount = 0
- exgsframecount = 0
- hueframecount = 0
- exhuframecount = 0
-
- hdFramesAll = []
- exgFramesAll = []
- exgsFramesAll = []
- hueFramesAll = []
- exhuFramesAll = []
-
- while True:
- k = cv2.waitKey(1) & 0xFF
- if k == ord('v') or hdFrame is None:
- if hdframecount >= len(hdFramesAll):
- hdFrame = next(frame_processor(videoHD, 'hd'))
- hdFrame = imutils.resize(hdFrame, height=640)
- hdFrame = imutils.rotate(hdFrame, angle=180)
- hdframecount += 1
- hdFramesAll.append(hdFrame)
- else:
- hdFrame = hdFramesAll[hdframecount]
- hdframecount += 1
-
- if k == ord('q') or exgFrame is None:
- if exgframecount >= len(exgFramesAll):
- exgFrame = next(frame_processor(exgVideo, 'exg'))
- exgframecount += 1
- exgFramesAll.append(exgFrame)
- else:
- exgFrame = exgFramesAll[exgframecount]
- exgframecount += 1
-
- if k == ord('w') or exgsFrame is None:
- if exgsframecount >= len(exgsFramesAll):
- exgsFrame = next(frame_processor(exgsVideo, 'exgs'))
- exgsframecount += 1
- exgsFramesAll.append(exgsFrame)
- else:
- exgsFrame = exgsFramesAll[exgsframecount]
- exgsframecount += 1
-
- if k == ord('e') or hueFrame is None:
- if hueframecount >= len(hueFramesAll):
- hueFrame = next(frame_processor(hueVideo, 'hsv'))
- hueframecount += 1
- hueFramesAll.append(hueFrame)
- else:
- hueFrame = hueFramesAll[hueframecount]
- hueframecount += 1
-
- if k == ord('r') or exhuFrame is None:
- if exhuframecount >= len(exhuFramesAll):
- exhuFrame = next(frame_processor(exhuVideo, 'exhu'))
- exhuframecount += 1
- exhuFramesAll.append(exhuFrame)
- else:
- exhuFrame = exhuFramesAll[exhuframecount]
- exhuframecount += 1
-
- if k == ord('b'):
- if hdframecount > 0:
- hdframecount -= 1
- hdFrame = hdFramesAll[hdframecount]
- else:
- hdFrame = hdFramesAll[hdframecount]
-
- if k == ord('a'):
- if exgframecount > 0:
- exgframecount -= 1
- exgFrame = exgFramesAll[exgframecount]
- else:
- exgFrame = exgFramesAll[exgframecount]
-
- if k == ord('s'):
- if exgsframecount > 0:
- exgsframecount -= 1
- exgsFrame = exgsFramesAll[exgsframecount]
- else:
- exgsFrame = exgsFramesAll[exgsframecount]
-
- if k == ord('d'):
- if hueframecount > 0:
- hueframecount -= 1
- hueFrame = hueFramesAll[hueframecount]
- else:
- hueFrame = hueFramesAll[hueframecount]
-
- if k == ord('f'):
- if exhuframecount > 0:
- exhuframecount -= 1
- exhuFrame = exhuFramesAll[exhuframecount]
- else:
- exhuFrame = exhuFramesAll[exhuframecount]
-
- # save current frames for the video comparison
- if k == ord('y'):
- cv2.imwrite('images/frameGrabs/{}_frame{}_exg.png'.format(baseName, exgframecount), exgFrame)
- cv2.imwrite('images/frameGrabs/{}_frame{}_exgs.png'.format(baseName, exgsframecount), exgsFrame)
- cv2.imwrite('images/frameGrabs/{}_frame{}_hue.png'.format(baseName, hueframecount), hueFrame)
- cv2.imwrite('images/frameGrabs/{}_frame{}_exhu.png'.format(baseName, exhuframecount), exhuFrame)
- print('[INFO] All frames written.')
-
- # write text on each video frame
- exgVis = exgFrame.copy()
- exgsVis = exgsFrame.copy()
- hueVis = hueFrame.copy()
- exhuVis = exhuFrame.copy()
-
- cv2.putText(exhuVis, 'exhu: {} / {}'.format(exhuframecount, lenexhu), (50, 50), cv2.FONT_HERSHEY_SIMPLEX, 0.5,
- (0, 255, 0), 2)
- cv2.putText(hueVis, 'hue: {} / {}'.format(hueframecount, lenhue), (50, 50), cv2.FONT_HERSHEY_SIMPLEX, 0.5,
- (0, 255, 0), 2)
- cv2.putText(exgsVis, 'exgs: {} / {}'.format(exgsframecount, lenexgs), (50, 50), cv2.FONT_HERSHEY_SIMPLEX, 0.5,
- (0, 255, 0), 2)
- cv2.putText(exgVis, 'exg: {} / {}'.format(exgframecount, lenexg), (50, 50), cv2.FONT_HERSHEY_SIMPLEX, 0.5,
- (0, 255, 0), 2)
- cv2.putText(hdFrame, 'HD: {} / {}'.format(hdframecount, lenHD), (50, 50), cv2.FONT_HERSHEY_SIMPLEX, 0.5,
- (0, 255, 0), 2)
-
- # stack the video frames
- topRow = np.hstack((exgVis, exgsVis))
- bottomRow = np.hstack((hueVis, exhuVis))
- combined = np.vstack((topRow, bottomRow))
- combined = np.hstack((combined, hdFrame))
-
- cv2.imshow('Output', combined)
-
- if k == 27:
- break
-
-
-def single_frame_analysis(videoFile: str, HDFile: str, algorithm='exhsv'):
- baseName = os.path.splitext(os.path.basename(videoFile))[0]
-
- video = cv2.VideoCapture(videoFile)
- print("[INFO] Loaded {}".format(video))
- lenVideo = count_frames(videoFile, override=True) - 1
-
- videoHD = cv2.VideoCapture(HDFile)
- print("[INFO] Loaded {}".format(HDFile))
- lenHD = count_frames(HDFile, override=True) - 1
-
- hdFrame = None
- videoFrame = None
-
- hdframecount = 0
- videoframecount = 0
-
- hdFramesAll = []
- videoFramesAll = []
-
- while True:
- k = cv2.waitKey(1) & 0xFF
- if k == ord('d') or hdFrame is None:
- if hdframecount >= len(hdFramesAll):
- hdFrame = next(frame_processor(videoHD, videoName='hd'))
- hdFrame = imutils.resize(hdFrame, height=640)
- # hdFrame = imutils.rotate(hdFrame, angle=180)
- hdframecount += 1
- hdFramesAll.append(hdFrame)
- else:
- hdFrame = hdFramesAll[hdframecount]
- hdframecount += 1
-
- if k == ord('s') or videoFrame is None:
- if videoframecount >= len(videoFramesAll):
- videoFrame = next(frame_processor(video, algorithm=algorithm))
- videoFrame = imutils.resize(videoFrame, height=640)
- videoframecount += 1
- videoFramesAll.append(videoFrame)
- else:
- videoFrame = videoFramesAll[videoframecount]
- videoframecount += 1
-
- if k == ord('e'):
- if hdframecount > 0:
- hdframecount -= 1
- hdFrame = hdFramesAll[hdframecount]
- else:
- hdFrame = hdFramesAll[hdframecount]
-
- if k == ord('w'):
- if videoframecount > 0:
- videoframecount -= 1
- videoFrame = videoFramesAll[videoframecount]
- else:
- videoFrame = videoFramesAll[videoframecount]
-
- # save current frames for the video comparison
- if k == ord('y'):
- cv2.imwrite('images/frameGrabs/{}_frame{}_{}.png'.format(baseName, videoframecount, algorithm), videoFrame)
- print('[INFO] All frames written.')
-
- # write text on each video frame
- videoVis = videoFrame.copy()
-
- cv2.putText(videoVis, '!CHECK! -> {}: {} / {}'.format(algorithm, videoframecount, lenVideo), (50, 50),
- cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)
- cv2.putText(hdFrame, 'HD: {} / {}'.format(hdframecount, lenHD), (50, 50), cv2.FONT_HERSHEY_SIMPLEX, 1,
- (0, 255, 0), 2)
-
- # stack the video frames
- combined = np.hstack((videoVis, hdFrame))
- cv2.putText(combined, 'Controls: "d/e" - HD fwd/back | "s/w" - vid fwd/back | ESC - quit | "y" - save frame'.format(algorithm, videoframecount, lenVideo), (450, 620),
- cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 2)
- cv2.imshow('Output', combined)
-
- if k == 27:
- break
-
-
-def frame_processor(videoFeed, videoName='', algorithm='exhsv'):
- frameShape = None
- while True:
- k = cv2.waitKey(1) & 0xFF
- ret, frame = videoFeed.read()
-
- if ret == False:
- frame = np.zeros(frameShape, dtype='uint8')
-
- if frameShape is None:
- frameShape = frame.shape
-
- if videoName == "hd":
- yield frame
-
- else:
- cnts, boxes, weedCentres, imageOut = green_on_brown(frame, exgMin=29,
- exgMax=200,
- hueMin=30,
- hueMax=92,
- saturationMin=10,
- saturationMax=250,
- brightnessMin=60,
- brightnessMax=250,
- show_display=False,
- algorithm=algorithm, minArea=10)
-
- yield imageOut
- if k == 27:
- videoFeed.stop()
- break
-
-
-def size_analysis(directory, sample_number=10, save_directory=None):
- '''
- take a directory of videos, save all frames to a list, randomly sample X number of frames, run EXHSV algorithm that returns contour list
- iterate over each contour and save frame ID, contour ID, contour area, bbox area, calibrated area
- :param directory:
- :return:
- '''
- ### IMPORTANT ###
- # this sets the random state - random values won't change unless you change this number
- RANDOM_STATE = 42
- np.random.seed(RANDOM_STATE)
- #################
-
- ### ALSO IMPORTANT ###
- # based on bench calibration - changing this will change the calibrated area
- # structure: 'camera': (on_ground_width_in_mm / image_width_pixels) ** 2 = area of one pixel
- calibration_dictionary = {
- 'ard': (964 / 416) ** 2,
- 'hq1': (1125 / 640) ** 2,
- 'hq2': (1125 / 416) ** 2,
- 'v2': (1153 / 416) ** 2,
- }
- df_columns = ['video_name', 'camera', 'rep', 'speed', 'frame_id',
- 'contour_px_area', 'bbox_px_area', 'mm2_contour_area', 'mm2_bbox_area']
-
- df = pd.DataFrame(columns=df_columns)
-
- for videoPath in tqdm(glob.iglob(directory + '\*.mp4')):
- video_name = os.path.basename(videoPath).split('.')[0]
- camera_name = video_name.split('-')[0].lower()
- rep = video_name.split('-')[1]
- speed = video_name.split('-')[2]
-
- cap = cv2.VideoCapture(videoPath)
- video_length = count_frames(videoPath, override=True) - 1
-
- # randomly sample frames
- for i in tqdm(range(sample_number)):
-
- randint = np.random.randint(0, video_length)
- cap.set(1, randint)
- ret, frame = cap.read()
-
- # uses same parameters as the above image analysis settings
- cnts, boxes, weedCentres, imageOut = green_on_brown(frame.copy(), exgMin=29,
- exgMax=200,
- hueMin=30,
- hueMax=92,
- saturationMin=10,
- saturationMax=250,
- brightnessMin=60,
- brightnessMax=250,
- show_display=False,
- algorithm='exhsv', minArea=-10)
- # cv2.imshow('Output', imageOut)
- # cv2.waitKey(10)
- # calculate and append the individual contour areas
- px_contour_area = []
- cal_contour_area = []
- px_bbox_area = []
- cal_bbox_area = []
-
- if save_directory is not None:
- save_frame = frame.copy()
- sample_thread = Thread(target=bounding_box_image_sample,
- args=[save_frame, boxes, save_directory, randint])
- sample_thread.start()
-
- whole_image_thread = Thread(target=whole_image_save,
- args=[imageOut, save_directory, randint])
- whole_image_thread.start()
-
-
- for c in cnts:
- c_px_area = cv2.contourArea(c)
- c_cal_area = c_px_area * calibration_dictionary[camera_name]
- px_contour_area.append(c_px_area)
- cal_contour_area.append(c_cal_area)
-
- for box in boxes:
- boxW = box[2]
- boxH = box[3]
-
- bbox_px_area = boxW * boxH
- bbox_cal_area = bbox_px_area * calibration_dictionary[camera_name]
-
- px_bbox_area.append(bbox_px_area)
- cal_bbox_area.append(bbox_cal_area)
-
- print(px_bbox_area)
- print("-----------------------")
- print(cal_bbox_area)
- frame_id = [randint for x in boxes]
- video_name_id = [video_name for x in boxes]
- camera_id = [camera_name for x in boxes]
- rep_id = [rep for x in boxes]
- speed_id = [speed for x in boxes]
-
- df2 = pd.DataFrame(list(zip(video_name_id, camera_id, rep_id, speed_id, frame_id,
- px_contour_area, px_bbox_area, cal_contour_area, cal_bbox_area)),
- columns=df_columns)
-
- print(df2)
- df = df.append(df2)
-
- df.to_csv(r"logs\{}_size_analysis_rstate_{}.csv".format(datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S"),
- RANDOM_STATE))
- time.sleep(2)
-
-def blur_analysis(directory, sample_number=10, save_directory=None, display=False):
- ### IMPORTANT ###
- # this sets the random state - random values won't change unless you change this number
- RANDOM_STATE = 42
- np.random.seed(RANDOM_STATE)
- #################
-
- df_columns = ['video_name', 'camera', 'rep', 'speed', 'frame_id', 'blur']
- df = pd.DataFrame(columns=df_columns)
-
- for videoPath in tqdm(glob.iglob(directory + '\*.mp4')):
- blur_list = []
- video_name = os.path.basename(videoPath).split('.')[0]
- camera_name = video_name.split('-')[0].lower()
- rep = video_name.split('-')[1]
- speed = video_name.split('-')[2]
-
- cap = cv2.VideoCapture(videoPath)
- video_length = count_frames(videoPath, override=True) - 1
-
- # randomly sample frames
- for i in tqdm(range(sample_number)):
- randint = np.random.randint(0, video_length)
- cap.set(1, randint)
- ret, frame = cap.read()
-
- if save_directory is not None:
- save_frame = frame.copy()
-
- whole_image_thread = Thread(target=whole_image_save,
- args=[save_frame, save_directory, randint])
- whole_image_thread.start()
-
- greyscale = cv2.cvtColor(frame.copy(), cv2.COLOR_BGR2GRAY)
- blurriness = cv2.Laplacian(greyscale, cv2.CV_64F).var()
- blur_list.append(blurriness)
-
- data = [video_name, camera_name, rep, speed, randint, blurriness]
- df2 = pd.DataFrame([data], columns=df_columns)
- if display:
- display_frame = frame.copy()
- cv2.putText(display_frame, 'SPEED: {} | BLUR: {}'.format(speed, blurriness), (20, 35), cv2.FONT_HERSHEY_SIMPLEX, 0.75,
- (80, 80, 255), 1)
- cv2.imshow(video_name, display_frame)
- cv2.waitKey(1000)
- cv2.destroyAllWindows()
-
- df = df.append(df2)
- print("-----------------------")
- print(df)
-
- df.to_csv(r"logs\{}_BLUR_analysis_rstate_{}.csv".format(datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S"),
- RANDOM_STATE))
- time.sleep(2)
-
-
-if __name__ == "__main__":
- # videoFile = r"videos/HQ2-1-5.avi"
- # hdFile = r"videos/ard-1-5.avi"
- #
- # single_frame_analysis(videoFile=videoFile,
- # HDFile=hdFile,
- # algorithm='exg')
- #
- # # blur analysis
- directory = r"videos"
- save_directory = r'images/bbox'
- # size_analysis(directory=directory, save_directory=save_directory)
-
- blur_analysis(directory=directory,
- sample_number=20,
- save_directory=save_directory,
- display=False)
-