From 17ff8a5775311a7537a24b9334690e980534932d Mon Sep 17 00:00:00 2001 From: Joe Heffer Date: Thu, 4 Apr 2024 16:01:32 +0100 Subject: [PATCH 1/5] Add code comments and notes --- README.md | 53 +++++++++++++++++++++++++++++------- bee_track/battery.py | 4 +++ bee_track/camera.py | 27 ++++++++++++------- bee_track/configurable.py | 1 + bee_track/core.py | 56 +++++++++++++++++++-------------------- bee_track/rotate.py | 3 +-- bee_track/tracking.py | 2 +- bee_track/trigger.py | 9 ++++--- startup | 2 +- webinterface/track.js | 13 ++++++--- 10 files changed, 112 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 528ebc5..6c7a696 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,14 @@ -# bee_track -Tracking software to run on pi +# Bumblebee Tracking System +This is a web-based bee tracking system that runs on a Raspberry Pi that may be accessed via a mobile phone. It controls a collection of cameras and captures data. -# Getting the pi set up +This code repository contains two systems: + +- a front-end user interface implemented in HTML and Javascript; +- a back-end API implemented using the Flask web framework. + +# Installation + +## Getting the RPi set up To make it possible to ssh into, use: @@ -23,7 +30,7 @@ Edit `/etc/wpa_supplicant/wpa_supplicant.conf`, enter: psk="PASSWORD" } -# Installation +# Dependencies Download the aravis library: @@ -105,18 +112,44 @@ frontend <--"HTTP 5000"--> backend ## API architecture -The Flask application has four components which run in separate threads: +The Flask application has four main components, which are workers that run in separate threads: -- Cameras -- Triggers -- Rotation -- Tracking +- Cameras: reads in image data from a camera. +- Trigger: handles triggering the GPIO pins, etc. +- Rotation: sends a rotation signal to a stepper motor via GPIO pins. +- Tracking: takes images from the greyscale camera's image queue (cam.photo_queue) and looks for the tag Each thread has a worker process with a configuration message queue. +## User interface + +The front-end is implemented using jQuery, a popular JavaScript library for manipulating web pages. In simple terms, each HTML form widget in [webinterface/index.html](./webinterface/index.html) has an associated behaviour determined by the JavaScript code in [webinterface/track.js](./webinterface/track.js), which listens for certain events such as button clicks and makes a call to the back-end API using AJAX (Asynchronous JavaScript and XML). + +| Button | Button ID | Endpoint | Behaviour | +| -------------- | --------- | -------- | --------------------------- | +| Capture: Start | `#start` | `/start` | Starts camera data capture. | +| | | | | +| | | | | + +There is a JavaScript function called `msg()` that prints message to the "console" which is a HTML text area. + # Usage -TODO +The user interface is designed to control the bee tracker system. + +1. Access the user interface +2. Enter a label for your files +3. Under capture, click "Start" +4. Under capture, click "Stop" + +## API usage + +The API returns HTTP responses. + +```bash +$ curl http://192.168.50.58:5000/getimagecount +42 +``` # Development diff --git a/bee_track/battery.py b/bee_track/battery.py index 84feaaf..a540534 100644 --- a/bee_track/battery.py +++ b/bee_track/battery.py @@ -25,6 +25,10 @@ def scale(v): def read_batteries(): + """ + Get battery level information. + """ + scaleval = 0.0164 # Open SPI bus spi = spidev.SpiDev() diff --git a/bee_track/camera.py b/bee_track/camera.py index 3b768f2..c302716 100644 --- a/bee_track/camera.py +++ b/bee_track/camera.py @@ -3,7 +3,7 @@ import pickle import threading -# TODO what's this? +# https://github.com/SheffieldMLtracking/QueueBuffer from QueueBuffer import QueueBuffer import numpy as np @@ -45,6 +45,9 @@ def downscalecolour(img, blocksize): class Camera(Configurable): + """ + A worker to reads in image data from a camera. + """ def __init__(self, message_queue, record, cam_trigger, cam_id=None): """ @@ -55,6 +58,7 @@ def __init__(self, message_queue, record, cam_trigger, cam_id=None): self.record = record self.label = multiprocessing.Array('c', 100) self.index = multiprocessing.Value('i', 0) + 'Incrementing identifier number per capture (i.e. image count)' self.savephotos = multiprocessing.Value('b', True) self.fastqueue = multiprocessing.Value('b', False) ###THIS WILL STOP PROCESSING self.test = multiprocessing.Value('b', False) @@ -106,8 +110,9 @@ def worker(self): while True: # print("waiting for photo") - if self.debug: print( - 'Blocking started for getting photo at %s' % (datetime.datetime.now().strftime("%Y%m%d_%H:%M:%S.%f"))) + if self.debug: + print('Blocking started for getting photo at %s' % ( + datetime.datetime.now().strftime("%Y%m%d_%H:%M:%S.%f"))) photo = self.get_photo(getraw=self.fastqueue.value) print(".", end="", flush=True) if self.debug: print('Got photo at %s' % (datetime.datetime.now().strftime("%Y%m%d_%H:%M:%S.%f"))) @@ -191,12 +196,14 @@ def worker(self): camidstr, triggertime_string, self.label.value.decode('utf-8'), self.index.value) photo_object['filename'] = filename self.message_queue.put("Saved Photo: %s" % filename) - if self.debug: print( - 'starting save at %s' % (datetime.datetime.now().strftime("%Y%m%d_%H:%M:%S.%f"))) - pickle.dump(photo_object, open(filename, 'wb')) - if self.debug: print( - 'finished save at %s' % (datetime.datetime.now().strftime("%Y%m%d_%H:%M:%S.%f"))) - if self.info: print("Saved photo as %s" % filename) + if self.debug: + print('starting save at %s' % (datetime.datetime.now().strftime("%Y%m%d_%H:%M:%S.%f"))) + with open(filename, 'wb') as file: + pickle.dump(photo_object, file) + if self.debug: + print('finished save at %s' % (datetime.datetime.now().strftime("%Y%m%d_%H:%M:%S.%f"))) + if self.info: + print("Saved photo as %s" % filename) else: filename = 'photo_object_%s_%s.np' % ( camidstr, datetime.datetime.now().strftime("%Y%m%d_%H:%M:%S.%f")) @@ -206,7 +213,7 @@ def worker(self): self.message_queue.put("Saved Photo: %s" % filename) # np.save(open('photo_%04i.np' % self.index.value,'wb'),photo.astype(np.ubyte)) print("Incrementing camera index, from %d" % self.index.value) - self.index.value = self.index.value + 1 + self.index.value += 1 def close(self): """ diff --git a/bee_track/configurable.py b/bee_track/configurable.py index 36d0e09..f4a5194 100644 --- a/bee_track/configurable.py +++ b/bee_track/configurable.py @@ -1,4 +1,5 @@ import multiprocessing.sharedctypes +import threading class Configurable: diff --git a/bee_track/core.py b/bee_track/core.py index 4ffe2cd..cb6fe7d 100644 --- a/bee_track/core.py +++ b/bee_track/core.py @@ -15,6 +15,7 @@ from glob import glob from datetime import datetime as dt from typing import Optional +import threading import psutil import flask @@ -145,8 +146,11 @@ def getdiskfree(): @app.route('/getbattery') def getbattery(): + """ + Log battery info to file + """ batstr = read_batteries() - # Log battery info to file + # Append to text file with open("battery_status.txt", "a") as battery: battery.write(batstr) return batstr @@ -297,19 +301,17 @@ def start(): """ Start camera data capture. """ - # TODO what is the index? - nextindex = max([camera.index.value for camera in cameras] + [trigger.index.value]) + # Find the highest value of the index for any camera or the trigger + next_index = max([camera.index.value for camera in cameras] + [trigger.index.value]) - # reset indicies + # Set all camera indices and the trigger index to that value for camera in cameras: - camera.index.value = nextindex - trigger.index.value = nextindex - - # for camera in cameras: - # app.logger.info(camera.index.value) - # app.logger.info(trigger.index.value) + camera.index.value = next_index + trigger.index.value = next_index + # Enable the trigger event trigger.run.set() + return "Collection Started" @@ -318,6 +320,7 @@ def stop(): """ Stop camera data capture """ + # Disable the trigger event trigger.run.clear() return "Collection Stopped" @@ -396,27 +399,20 @@ def zip(): return "Zipping Started" -from threading import Thread -from time import sleep - - def threaded_function(): - while (True): + # TODO this should probably be a separate service, not part of the web app + while True: app.logger.info("running auto zip") # zip() #disabled - sleep(600) + raise NotImplementedError + time.sleep(600) -thread = Thread(target=threaded_function) +# TODO what's this for ??? +thread = threading.Thread(target=threaded_function) thread.start() -# import threading -# ticker = threading.Event() -# while not ticker.wait(600): -# app.logger.info("AUTO ZIP") -# zip() - @app.route('/update') def update(): # res = "" @@ -454,10 +450,13 @@ def lowresmaximg(img, blocksize=10): @app.route('/getimagecount') def getimagecount(): + """ + Get the total number of images. + """ try: - return str(cameras[0].index.value - 1) # gets index of current image... - # return str(cameras[0].photo_queue.len()) - except Empty: + # Get index of current image + return str(cameras[0].index.value - 1) + except IndexError: return "No items" @@ -467,7 +466,7 @@ def getimagewithindex(photo_queue, idx): if item is None: continue if item['index'] == idx: return item - return None + return @app.route('/getimage//') @@ -496,7 +495,7 @@ def getimage(number, camera_id=0): track['y'] = int(track['y']) newtracklist.append(track) else: - newtracklist = [] + newtracklist = list() return jsonify( {'index': photoitem['index'], 'photo': img.tolist(), 'record': photoitem['record'], 'track': newtracklist}) @@ -567,6 +566,7 @@ def getimagecentre(number: int, camera_id: int = 0): def main(): startup() + # Listen on all incoming addresses app.run(host="0.0.0.0") diff --git a/bee_track/rotate.py b/bee_track/rotate.py index 4a947be..348cb9d 100644 --- a/bee_track/rotate.py +++ b/bee_track/rotate.py @@ -1,4 +1,3 @@ -import datetime import multiprocessing import time @@ -11,7 +10,7 @@ class Rotate(Configurable): """ - This is a worker that sends a rotation signal via GPIO pins. + This is a worker that sends a rotation signal to a stepper motor via GPIO pins. """ def __init__(self, message_queue): diff --git a/bee_track/tracking.py b/bee_track/tracking.py index e243b2e..e8d0352 100644 --- a/bee_track/tracking.py +++ b/bee_track/tracking.py @@ -93,7 +93,7 @@ def __init__(self, img, tagloc): class Tracking(Configurable): """ - TODO + Takes images from the greyscale camera's image queue (cam.photo_queue) and looks for the retro-reflector tag. """ def __init__(self, message_queue, photo_queue): diff --git a/bee_track/trigger.py b/bee_track/trigger.py index f6d37c5..14c3777 100644 --- a/bee_track/trigger.py +++ b/bee_track/trigger.py @@ -12,7 +12,9 @@ class Trigger(Configurable): """ - Send a signal to the camera to take an exposure. + A worker to handle triggering the GPIO pins, etc. + + E.g. Send a signal to the camera to take an exposure. """ def __init__(self, message_queue, cam_trigger, t=2.0): @@ -22,6 +24,7 @@ def __init__(self, message_queue, cam_trigger, t=2.0): self.manager = multiprocessing.Manager() self.flashselection = self.manager.list() self.index = multiprocessing.Value('i', 0) + 'Incrementing identifier number per trigger event' self.record = self.manager.list() self.direction = 0 self.flash_select_pins = [14, 15, 18, 23] # [8,10,12,16] #Board->BCM pins @@ -50,7 +53,7 @@ def __init__(self, message_queue, cam_trigger, t=2.0): def trigger_camera(self, fireflash: bool, endofset: bool): """ - Send trigger to camera (and flash) + Send trigger to camera (and flash, optionally.) fireflash = boolean: true=fire flash endofset = boolean: whether this is the last photo of a set (this will then tell the tracking system to look for the bee). @@ -88,7 +91,7 @@ def trigger_camera(self, fireflash: bool, endofset: bool): 'flashselection': list(self.flashselection), 'triggertime': triggertime, 'triggertimestring': triggertimestring}) print("Incrementing trigger index from %d" % self.index.value) - self.index.value = self.index.value + 1 + self.index.value += 1 # Software trigger... # self.cam_trigger.set() diff --git a/startup b/startup index 7b06a35..0b9dd1d 100755 --- a/startup +++ b/startup @@ -2,7 +2,7 @@ sleep 20 export GI_TYPELIB_PATH=$GI_TYPELIB_PATH:/home/pi/aravis/build/src export LD_LIBRARY_PATH=/home/pi/aravis/build/src cd /home/pi/bee_track/webinterface -python3 -m http.server & +python3 -m http.server 8000 & sudo sysctl -w net.core.rmem_max=67108864 net.core.rmem_default=67108864 sudo sysctl -w net.ipv4.route.flush=1 diff --git a/webinterface/track.js b/webinterface/track.js index 0a71c41..0de48b1 100644 --- a/webinterface/track.js +++ b/webinterface/track.js @@ -12,16 +12,20 @@ function getdatestring() { return st } +// Function to display a message in the console and scroll to the bottom function msg(message) { + // Ensure the message ends with a new-line character if (message.slice(-1) != '\n') { message = message + "\n"; } - $("#console").val($("#console").val() + message);// + "\n"); + // Append message to console + $("#console").val($("#console").val() + message); + + // Scroll the console to the bottom to display the latest message var con = $('#console'); con.scrollTop(con[0].scrollHeight - con.height()); } - image = 0 imagecount = 0 $("input#url").val(window.location.hostname + ":5000") @@ -37,6 +41,7 @@ $.ajax({ }); $('button#start').click(function () { msg('Starting...'); + // Construct the URL of the API endpoint url = "http://" + $('input#url').val() + "/start"; $.ajax({ url: url, @@ -74,7 +79,9 @@ $('button#imagedownx10').click(function () { image = image - 10; }) +// Regular status updates +// Get free disk space on tracker setInterval(function () { url = "http://" + $('input#url').val() + "/getdiskfree"; $.ajax({ @@ -85,6 +92,7 @@ setInterval(function () { }); }, 60000); +// Get battery level setInterval(function () { url = "http://" + $('input#url').val() + "/getbattery"; $.ajax({ @@ -102,7 +110,6 @@ setInterval(function () { setInterval(function () { - url = "http://" + $('input#url').val() + "/getimagecount"; $.ajax({ url: url, From c0e611af2332999846b91f4bc0f993f2edac9360 Mon Sep 17 00:00:00 2001 From: Joe Heffer Date: Tue, 9 Apr 2024 15:58:32 +0100 Subject: [PATCH 2/5] Add documentation for the trigger code --- README.md | 35 +++++- bee_track/camera_aravis.py | 12 +- bee_track/core.py | 7 +- bee_track/trigger.py | 218 ++++++++++++++++++++++++++++--------- 4 files changed, 209 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 6c7a696..0b40d45 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ This is a web-based bee tracking system that runs on a Raspberry Pi that may be This code repository contains two systems: - a front-end user interface implemented in HTML and Javascript; -- a back-end API implemented using the Flask web framework. +- a back-end API implemented using the Flask web framework. # Installation @@ -32,11 +32,11 @@ Edit `/etc/wpa_supplicant/wpa_supplicant.conf`, enter: # Dependencies -Download the aravis library: +Download the Aravis library: git clone https://github.com/AravisProject/aravis.git -Or donwload earlier version from +Or download earlier version from http://ftp.gnome.org/pub/GNOME/sources/aravis/0.6/ then if you need the viewer (although I did find I had to split these installs). @@ -117,10 +117,25 @@ The Flask application has four main components, which are workers that run in se - Cameras: reads in image data from a camera. - Trigger: handles triggering the GPIO pins, etc. - Rotation: sends a rotation signal to a stepper motor via GPIO pins. -- Tracking: takes images from the greyscale camera's image queue (cam.photo_queue) and looks for the tag +- Tracking: takes images from the greyscale camera's image queue (`cam.photo_queue`) and looks for the tag Each thread has a worker process with a configuration message queue. +## Camera capture process + +Photos are captured in "sets" which are ??? + +1. Click "Start" on the GUI. +2. API activates `Trigger.run` event +3. (Optional) Delay start: wait +4. Activate flashes (via their GPIO pins) +5. Wait for preparation time (20 microseconds) +6. Record a photo capture in the exposure log. +7. Increment the trigger index (photo exposure counter). +8. Trigger camera exposure (activate GPIO pin) +9. Wait for the exposure/trigger time (30 microseconds) +10. Deactivate the trigger pins for the camera and flashes. + ## User interface The front-end is implemented using jQuery, a popular JavaScript library for manipulating web pages. In simple terms, each HTML form widget in [webinterface/index.html](./webinterface/index.html) has an associated behaviour determined by the JavaScript code in [webinterface/track.js](./webinterface/track.js), which listens for certain events such as button clicks and makes a call to the back-end API using AJAX (Asynchronous JavaScript and XML). @@ -128,7 +143,7 @@ The front-end is implemented using jQuery, a popular JavaScript library for mani | Button | Button ID | Endpoint | Behaviour | | -------------- | --------- | -------- | --------------------------- | | Capture: Start | `#start` | `/start` | Starts camera data capture. | -| | | | | +| Capture: Stop | `#stop` | `/stop` | Stops camera data capture. | | | | | | There is a JavaScript function called `msg()` that prints message to the "console" which is a HTML text area. @@ -151,6 +166,16 @@ $ curl http://192.168.50.58:5000/getimagecount 42 ``` +# Configuration + +There are endpoints to set and get configuration options for each of the components. + +The configuration options are stored in this file, which is loaded when the app starts up. + +``` +/home/pi/bee_track/webinterface/configvals.pkl +``` + # Development ## Testing diff --git a/bee_track/camera_aravis.py b/bee_track/camera_aravis.py index 206b5a7..48c3acf 100644 --- a/bee_track/camera_aravis.py +++ b/bee_track/camera_aravis.py @@ -1,13 +1,11 @@ +""" +Aravis Camera interface +https://github.com/SintefManufacturing/python-aravis/blob/master/aravis.py +""" + import logging -import sys -import time -import pickle -import ctypes -import threading import gc import os -from datetime import datetime as dt -from multiprocessing import Queue import numpy as np # TODO what's this gi.repository? diff --git a/bee_track/core.py b/bee_track/core.py index cb6fe7d..e6b3869 100644 --- a/bee_track/core.py +++ b/bee_track/core.py @@ -81,7 +81,8 @@ def setdatetime(timestring): def addtoconfigvals(component, field, value): try: - configvals = pickle.load(open('configvals.pkl', 'rb')) + with open('configvals.pkl', 'rb') as file: + configvals = pickle.load(file) if component not in configvals: configvals[component] = {} # if field not in configvals[component]: configvals[component][field] = value @@ -116,12 +117,16 @@ def setfromconfigvals(): Set app config values based on saved values. """ try: + # Load configuration file with open('configvals.pkl', 'rb') as file: configvals = pickle.load(file) app.logger.info("Loaded %s", file.name) + + # Set options for the components for component, fields in configvals.items(): for field, value in fields.items(): set(component, field, value) + # TODO don't ignore all errors except: # FileNotFoundError: pass diff --git a/bee_track/trigger.py b/bee_track/trigger.py index 14c3777..c2fe441 100644 --- a/bee_track/trigger.py +++ b/bee_track/trigger.py @@ -1,6 +1,7 @@ import logging import time import datetime +from enum import Enum import multiprocessing import RPi.GPIO as GPIO @@ -10,11 +11,28 @@ logger = logging.getLogger(__name__) +class FlashSequence(Enum): + """ + Flash sequence options + """ + ALL = 0 + "Fire every flash" + TWO_AT_A_TIME = 1 + "Fire two flashes at once" + IN_SEQUENCE = 2 + "Flash all the flashes sequentially" + NONE = 9 + "Disable all flashes" + + class Trigger(Configurable): """ A worker to handle triggering the GPIO pins, etc. E.g. Send a signal to the camera to take an exposure. + + Raspberry Pi GPIO module basics + https://sourceforge.net/p/raspberry-gpio-python/wiki/BasicUsage/ """ def __init__(self, message_queue, cam_trigger, t=2.0): @@ -22,74 +40,145 @@ def __init__(self, message_queue, cam_trigger, t=2.0): print("Initialising Trigger Control") self.cam_trigger = cam_trigger self.manager = multiprocessing.Manager() - self.flashselection = self.manager.list() + self.flashselection: list = self.manager.list() self.index = multiprocessing.Value('i', 0) - 'Incrementing identifier number per trigger event' - self.record = self.manager.list() + "Incrementing identifier number per trigger event" + self.record: list = self.manager.list() + "Camera exposure trigger event log" self.direction = 0 self.flash_select_pins = [14, 15, 18, 23] # [8,10,12,16] #Board->BCM pins + "Custom flash selection" self.trigger_pin = 24 # 18 #Board->BCM pins + # ??? self.flashselection.append(0) self.flashselection.append(1) self.flashselection.append(2) self.flashselection.append(3) self.t = multiprocessing.Value('d', t) + "Time interval/seconds" self.ds = multiprocessing.Value('d', 0) - self.flashseq = multiprocessing.Value('i', 0) # 0 = flash all, 1 = flash 2 at a time, 1 = flash in sequence, - self.skipnoflashes = multiprocessing.Value('i', 0) # how many to skip + "Delayed start (proportion of time interval)" + self.flashseq = multiprocessing.Value('i', FlashSequence.ALL) + "Flash sequence" + self.skipnoflashes = multiprocessing.Value('i', 0) + "How many flashes to skip???" self.preptime = 0.02 + "Preparation time/seconds" self.triggertime = 0.03 # this will end up at least 200us + "Trigger exposure time" self.seqn = 0 - GPIO.setmode(GPIO.BCM) # GPIO.BOARD) #Board->BCM + "Flash sequence position tracker" + self.set_up_gpio() + self.run = multiprocessing.Event() + "Trigger activation flag" + self.mode = GPIO.BCM # GPIO.BOARD, GPIO.BCM or None + "Pin numbering mode. See: https://sourceforge.net/p/raspberry-gpio-python/wiki/BasicUsage/" + + def set_up_gpio(self): + """ + Set up GPIO + + https://sourceforge.net/p/raspberry-gpio-python/wiki/BasicUsage/ + """ + + # Set GPIO pin numbering mode + GPIO.setmode(self.mode) + + # Set up pins as outputs (GPIO.OUT is output) GPIO.setup(self.trigger_pin, GPIO.OUT) for pin in self.flash_select_pins: GPIO.setup(pin, GPIO.OUT) + time.sleep(0.5) + + # Disable those output pins + self.deactivate_flashes() + self.deactivate() + + print("Running") + + @property + def flash_count(self) -> int: + """ + The number of flashes + """ + return len(self.flashselection) + + def activate_flashes(self): + """ + Activate the GPIO pins to configure the flashes that should fire + """ + + # All flashes + if self.flashseq.value == FlashSequence.ALL: + for flash in self.flashselection: + GPIO.output(self.flash_select_pins[flash], True) + + # Two at a time + if self.flashseq.value == FlashSequence.TWO_AT_A_TIME: + # Activate two flashes based on our position in the sequence + GPIO.output(self.flash_select_pins[self.flashselection[self.seqn]], True) + GPIO.output(self.flash_select_pins[self.flashselection[self.seqn + 1]], True) + + # Loop through the flash sequence + self.seqn += 2 + if self.seqn >= self.flash_count: + self.seqn = 0 + + # Flash in sequence + if self.flashseq.value == FlashSequence.IN_SEQUENCE: + GPIO.output(self.flash_select_pins[self.flashselection[self.seqn]], True) + + # Loop through the flash sequence + self.seqn += 1 + if self.seqn >= self.flash_count: + self.seqn = 0 + + # Custom flash selection + if self.flashseq.value == FlashSequence.NONE: + self.deactivate_flashes() + + def deactivate_flashes(self): + """ + Deactivate all flash GPIO pins + """ for pin in self.flash_select_pins: GPIO.output(pin, False) - GPIO.output(self.trigger_pin, False) - print("Running") - self.run = multiprocessing.Event() - def trigger_camera(self, fireflash: bool, endofset: bool): + def trigger_camera(self, fire_flash: bool, end_of_set: bool): """ Send trigger to camera (and flash, optionally.) - fireflash = boolean: true=fire flash - endofset = boolean: whether this is the last photo of a set (this will then tell the tracking system to look for the bee). - """ - logger.debug("Photo: Flash" if fireflash else "Photo: No Flash") - - if fireflash: - if self.flashseq.value == 0: - for flash in self.flashselection: - GPIO.output(self.flash_select_pins[flash], True) - if self.flashseq.value == 1: - GPIO.output(self.flash_select_pins[self.flashselection[self.seqn]], True) - GPIO.output(self.flash_select_pins[self.flashselection[self.seqn + 1]], True) - self.seqn += 2 - if self.seqn >= len(self.flashselection): - self.seqn = 0 - if self.flashseq.value == 2: - GPIO.output(self.flash_select_pins[self.flashselection[self.seqn]], True) - self.seqn += 1 - if self.seqn >= len(self.flashselection): - self.seqn = 0 - if self.flashseq.value == 9: - for pin in self.flash_select_pins: - GPIO.output(pin, False) + fire_flash = boolean: true=fire flash + end_of_set = boolean: whether this is the last photo of a set (this will then tell the tracking system to look for the bee). + """ + logger.debug("Photo: Flash" if fire_flash else "Photo: No Flash") + + # Activate flash + if fire_flash: + self.activate_flashes() else: - for pin in self.flash_select_pins: - GPIO.output(pin, False) + # Deactivate all flash GPIO pins + self.deactivate_flashes() + + # Wait for preparation time time.sleep(self.preptime) - triggertime = time.time() # TODO Why are these two different? - triggertimestring = datetime.datetime.now() # need to convert to string later - triggertimestring = triggertimestring.strftime("%Y%m%d_%H:%M:%S.%f") - self.record.append( - {'index': self.index.value, 'endofset': endofset, 'direction': self.direction, 'flash': fireflash, - 'flashselection': list(self.flashselection), 'triggertime': triggertime, - 'triggertimestring': triggertimestring}) + # Get current timestamp + triggertime = time.time() # TODO Why are these two different? + triggertime_datetime = datetime.datetime.now() # need to convert to string later + # triggertime = triggertime_datetime.timestamp() + triggertimestring = triggertime_datetime.strftime("%Y%m%d_%H:%M:%S.%f") + + # Log the photo capture + record = { + 'index': self.index.value, 'endofset': end_of_set, 'direction': self.direction, 'flash': fire_flash, + 'flashselection': list(self.flashselection), 'triggertime': triggertime, + 'triggertimestring': triggertimestring + } + self.record.append(record) + + # Increment exposure count print("Incrementing trigger index from %d" % self.index.value) self.index.value += 1 @@ -97,28 +186,57 @@ def trigger_camera(self, fireflash: bool, endofset: bool): # self.cam_trigger.set() # Trigger via pin... - GPIO.output(self.trigger_pin, True) - + self.activate() time.sleep(self.triggertime) - for pin in self.flash_select_pins: - GPIO.output(pin, False) - # (trigger via pin)... + # Deactivate + self.deactivate_flashes() + self.deactivate() + + def activate(self): + """ + Turn on the trigger GPIO pin + """ + GPIO.output(self.trigger_pin, True) + + def deactivate(self): + """ + Turn off the trigger GPIO pin + """ GPIO.output(self.trigger_pin, False) def worker(self): + """ + Tell the camera to take exposures when the trigger flag is activated. + """ + + # ??? skipcount = 0 + + # Loop until the run event is deactivated (cleared) while True: self.run.wait() + + # Delayed start delaystart = self.ds.value * self.t.value time.sleep(delaystart) + + # Increment skip counter skipcount += 1 - skipnoflashphoto = (skipcount <= self.skipnoflashes.value) - self.trigger_camera(True, skipnoflashphoto) + # ??? + skipnoflashphoto = skipcount <= self.skipnoflashes.value + + # Capture data + self.trigger_camera(fire_flash=True, end_of_set=skipnoflashphoto) + + # Is something different happening when we reach the end of a set? What's a set? + # ??? if not skipnoflashphoto: - self.trigger_camera(False, True) + self.trigger_camera(fire_flash=False, end_of_set=True) skipcount = 0 + # Wait for ??? time.sleep(self.t.value - self.triggertime * 2 - self.preptime * 2 - delaystart) else: + # Wait for ??? time.sleep(self.t.value - self.triggertime - self.preptime - delaystart) From 0071b3786da06d7f2d42152a213209d94a1dd849 Mon Sep 17 00:00:00 2001 From: Joe Heffer Date: Tue, 9 Apr 2024 16:04:20 +0100 Subject: [PATCH 3/5] Use epytext docstring --- bee_track/trigger.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bee_track/trigger.py b/bee_track/trigger.py index c2fe441..18aaab9 100644 --- a/bee_track/trigger.py +++ b/bee_track/trigger.py @@ -147,10 +147,10 @@ def deactivate_flashes(self): def trigger_camera(self, fire_flash: bool, end_of_set: bool): """ - Send trigger to camera (and flash, optionally.) + Send trigger to camera (and flash, optionally) to take a photograph exposure. - fire_flash = boolean: true=fire flash - end_of_set = boolean: whether this is the last photo of a set (this will then tell the tracking system to look for the bee). + @param fire_flash: Whether to activate the flashes + @param end_of_set: whether this is the last photo of a set (tells the tracking system to look for the bee) """ logger.debug("Photo: Flash" if fire_flash else "Photo: No Flash") From b3244400631e6cebf7bccc83e10eb37ff4191720 Mon Sep 17 00:00:00 2001 From: Joe Heffer Date: Tue, 9 Apr 2024 16:40:47 +0100 Subject: [PATCH 4/5] Add comments and doc-strings --- README.md | 5 ++- bee_track/camera.py | 15 +++++--- bee_track/camera_aravis.py | 79 ++++++++++++++++++++++---------------- bee_track/core.py | 3 +- bee_track/trigger.py | 4 +- 5 files changed, 64 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 0b40d45..027f6fb 100644 --- a/README.md +++ b/README.md @@ -114,8 +114,8 @@ frontend <--"HTTP 5000"--> backend The Flask application has four main components, which are workers that run in separate threads: +- Trigger: handles triggering the camera and flashes using GPIO pins. - Cameras: reads in image data from a camera. -- Trigger: handles triggering the GPIO pins, etc. - Rotation: sends a rotation signal to a stepper motor via GPIO pins. - Tracking: takes images from the greyscale camera's image queue (`cam.photo_queue`) and looks for the tag @@ -134,7 +134,8 @@ Photos are captured in "sets" which are ??? 7. Increment the trigger index (photo exposure counter). 8. Trigger camera exposure (activate GPIO pin) 9. Wait for the exposure/trigger time (30 microseconds) -10. Deactivate the trigger pins for the camera and flashes. +10. Camera does something? +11. Deactivate the trigger pins for the camera and flashes. ## User interface diff --git a/bee_track/camera.py b/bee_track/camera.py index c302716..af46b1e 100644 --- a/bee_track/camera.py +++ b/bee_track/camera.py @@ -90,23 +90,27 @@ def camera_trigger(self): """ raise NotImplementedError - # def trigger(self): - # print("Triggering Camera") - # self.cam_trigger.set() - def get_photo(self, getraw: bool = False): """Blocking, returns a photo numpy array""" raise NotImplementedError def worker(self): + """ + Get image data from the camera??? + """ print("Camera worker started") self.setup_camera() + + # Start threads for the camera trigger and configuration t = threading.Thread(target=self.camera_trigger) t.start() t = threading.Thread(target=self.camera_config_worker) t.start() + print("Camera setup complete") last_photo_object = None + + # Indefinite loop while True: # print("waiting for photo") @@ -115,7 +119,8 @@ def worker(self): datetime.datetime.now().strftime("%Y%m%d_%H:%M:%S.%f"))) photo = self.get_photo(getraw=self.fastqueue.value) print(".", end="", flush=True) - if self.debug: print('Got photo at %s' % (datetime.datetime.now().strftime("%Y%m%d_%H:%M:%S.%f"))) + if self.debug: + print('Got photo at %s' % (datetime.datetime.now().strftime("%Y%m%d_%H:%M:%S.%f"))) if photo is None: print("Photo failed") diff --git a/bee_track/camera_aravis.py b/bee_track/camera_aravis.py index 48c3acf..0c99ef9 100644 --- a/bee_track/camera_aravis.py +++ b/bee_track/camera_aravis.py @@ -43,15 +43,13 @@ def count_devices(cls) -> int: return n_cams - @staticmethod - def get_camera_ids() -> list[str]: + @classmethod + def get_camera_ids(cls) -> list[str]: """ Get camera identifiers """ - # TODO convert to generator? (how long does get_device_id take to run?) - ids = list() - for i in range(n_cams): + for i in range(cls.count_devices()): # https://lazka.github.io/pgi-docs/Aravis-0.8/functions.html#Aravis.get_device_id dev_id: str = Aravis.get_device_id(i) logger.info("Found camera: %s" % dev_id) @@ -192,39 +190,51 @@ def setup_camera(self): print("Ready") def camera_config_worker(self): + """ + Listen for new settings/options in the camera configuration queue and set them on the device. + """ while True: + # Wait for a new configuration option to arrive config = self.config_camera_queue.get() - print("Got:") - print(config) - if config[0] == 'exposure': - self.aravis_camera.set_exposure_time(config[1]) - if config[0] == 'delay': - aravis_device = self.aravis_camera.get_device() - aravis_device.set_integer_feature_value("StrobeLineDelay", config[1]) - if config[0] == 'predelay': - aravis_device = self.aravis_camera.get_device() - aravis_device.set_integer_feature_value("StrobeLinePreDelay", config[1]) + print("Got:", config) + key = config[0] + value = config[1] + match key: + case 'exposure': + self.aravis_camera.set_exposure_time(value) + case 'delay': + aravis_device = self.aravis_camera.get_device() + aravis_device.set_integer_feature_value("StrobeLineDelay", value) + case 'predelay': + aravis_device = self.aravis_camera.get_device() + aravis_device.set_integer_feature_value("StrobeLinePreDelay", value) + case _: + raise ValueError(config) def camera_trigger(self): + """ + Software camera trigger + """ + while True: - if self.debug: print("WAITING FOR TRIGGER") + if self.debug: + print("WAITING FOR TRIGGER") + # Wait for software trigger self.cam_trigger.wait() - if self.debug: print("Software Trigger...") + if self.debug: + print("Software Trigger...") self.aravis_camera.software_trigger() self.cam_trigger.clear() - def get_photo(self, getraw=False): - if self.debug: print(self.cam_id, self.stream.get_n_buffers()) - if self.debug: print(self.cam_id, "waiting for photo...") + def get_photo(self, get_raw: bool = False): + if self.debug: + print(self.cam_id, self.stream.get_n_buffers()) + print(self.cam_id, "waiting for photo...") buffer = self.stream.pop_buffer() - # buffer = None - # while buffer is None: - # print("...") - # time.sleep(np.random.rand()*1) #wait between 0 and 1 second - # buffer = self.stream.timeout_pop_buffer(1000) #blocking for 1ms + if self.debug: + print(self.cam_id, "got buffer...") - if self.debug: print(self.cam_id, "got buffer...") if buffer is None: self.message_queue.put(self.cam_id + " Buffer read failed") print(self.cam_id, "buffer read failed") @@ -240,17 +250,20 @@ def get_photo(self, getraw=False): return None print("Stream statistics") print(self.stream.get_statistics()) - if self.debug: print(self.cam_id, "buffer ok") - if getraw: - raw = np.frombuffer(buffer.get_data(), dtype=np.uint8) # no type conversion! - else: - raw = np.frombuffer(buffer.get_data(), dtype=np.uint8).astype(float) + if self.debug: + print(self.cam_id, "buffer ok") + + # Get image data + raw = np.frombuffer(buffer.get_data(), dtype=np.uint8) + if not get_raw: + raw = raw.astype(float) self.stream.push_buffer(buffer) if bool(self.return_full_colour.value): print(">>>") - return np.reshape(raw, [self.height, self.width, 3]) + new_shape = [self.height, self.width, 3] else: - return np.reshape(raw, [self.height, self.width]) + new_shape = [self.height, self.width] + return np.reshape(raw, new_shape) def close(self): """ diff --git a/bee_track/core.py b/bee_track/core.py index e6b3869..6872873 100644 --- a/bee_track/core.py +++ b/bee_track/core.py @@ -269,7 +269,8 @@ def startup(): cam_ids = AravisCamera.get_camera_ids() if not cam_ids: raise bee_track.camera.NoCamerasFoundError - # Iterate over available cameras ands tart a process for each one + + # Iterate over available cameras and start a process for each one for cam_id in cam_ids: app.logger.info("Camera %s", cam_id) camera = AravisCamera(message_queue, trigger.record, cam_trigger, cam_id=cam_id) diff --git a/bee_track/trigger.py b/bee_track/trigger.py index 18aaab9..4bd6362 100644 --- a/bee_track/trigger.py +++ b/bee_track/trigger.py @@ -35,10 +35,11 @@ class Trigger(Configurable): https://sourceforge.net/p/raspberry-gpio-python/wiki/BasicUsage/ """ - def __init__(self, message_queue, cam_trigger, t=2.0): + def __init__(self, message_queue, cam_trigger: multiprocessing.Event, t: float = 2.0): super().__init__(message_queue) print("Initialising Trigger Control") self.cam_trigger = cam_trigger + "Camera exposure event" self.manager = multiprocessing.Manager() self.flashselection: list = self.manager.list() self.index = multiprocessing.Value('i', 0) @@ -183,6 +184,7 @@ def trigger_camera(self, fire_flash: bool, end_of_set: bool): self.index.value += 1 # Software trigger... + # This communicates to the software camera interface # self.cam_trigger.set() # Trigger via pin... From 33ce5ebd984453f67cb54e0e8cc75e276077355c Mon Sep 17 00:00:00 2001 From: Joe Heffer Date: Tue, 9 Apr 2024 16:58:38 +0100 Subject: [PATCH 5/5] Add comments and doc-strings --- README.md | 14 +++++++-- bee_track/camera.py | 59 +++++++++++++++++++++----------------- bee_track/camera_aravis.py | 15 ++++++---- bee_track/core.py | 2 +- 4 files changed, 53 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 027f6fb..b8bb03e 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,11 @@ Each thread has a worker process with a configuration message queue. ## Camera capture process -Photos are captured in "sets" which are ??? +### Camera exposure trigger + +The `Trigger` class waits for the instruction to activate the exposure pin. + +Photos are captured in "sets" which are synchronised with the flash sequence. 1. Click "Start" on the GUI. 2. API activates `Trigger.run` event @@ -133,10 +137,14 @@ Photos are captured in "sets" which are ??? 6. Record a photo capture in the exposure log. 7. Increment the trigger index (photo exposure counter). 8. Trigger camera exposure (activate GPIO pin) -9. Wait for the exposure/trigger time (30 microseconds) -10. Camera does something? +9. The camera captured data based on the GPIO pin activation. +10. Wait for the exposure/trigger time (30 microseconds) 11. Deactivate the trigger pins for the camera and flashes. +### Camera data retrieval + +The `Camera` class gets photos from the camera's data buffer. + ## User interface The front-end is implemented using jQuery, a popular JavaScript library for manipulating web pages. In simple terms, each HTML form widget in [webinterface/index.html](./webinterface/index.html) has an associated behaviour determined by the JavaScript code in [webinterface/track.js](./webinterface/track.js), which listens for certain events such as button clicks and makes a call to the back-end API using AJAX (Asynchronous JavaScript and XML). diff --git a/bee_track/camera.py b/bee_track/camera.py index af46b1e..d6112d7 100644 --- a/bee_track/camera.py +++ b/bee_track/camera.py @@ -2,6 +2,7 @@ import multiprocessing import pickle import threading +import logging # https://github.com/SheffieldMLtracking/QueueBuffer from QueueBuffer import QueueBuffer @@ -9,6 +10,8 @@ from configurable import Configurable +logger = logging.getLogger(__name__) + class CameraException(Exception): pass @@ -90,40 +93,41 @@ def camera_trigger(self): """ raise NotImplementedError - def get_photo(self, getraw: bool = False): - """Blocking, returns a photo numpy array""" + def get_photo(self, get_raw: bool = False) -> np.ndarray: + """ + Retrieve image data from the camera. + + This is a synchronous (blocking) function. + + :returns: Photo data (numpy array) + """ raise NotImplementedError def worker(self): """ - Get image data from the camera??? + Get image data from the camera. """ - print("Camera worker started") + logger.info("Camera worker started") self.setup_camera() - # Start threads for the camera trigger and configuration - t = threading.Thread(target=self.camera_trigger) - t.start() - t = threading.Thread(target=self.camera_config_worker) - t.start() + # Start threads to listen for camera trigger and configuration messages + threading.Thread(target=self.camera_trigger).start() + threading.Thread(target=self.camera_config_worker).start() + + logger.info("Camera setup complete") - print("Camera setup complete") + # ??? last_photo_object = None - # Indefinite loop + # Indefinite loop: transfer image data from the camera while True: - # print("waiting for photo") - - if self.debug: - print('Blocking started for getting photo at %s' % ( - datetime.datetime.now().strftime("%Y%m%d_%H:%M:%S.%f"))) - photo = self.get_photo(getraw=self.fastqueue.value) - print(".", end="", flush=True) - if self.debug: - print('Got photo at %s' % (datetime.datetime.now().strftime("%Y%m%d_%H:%M:%S.%f"))) + logger.debug('Blocking started for getting photo at %s' % ( + datetime.datetime.now().strftime("%Y%m%d_%H:%M:%S.%f"))) + photo = self.get_photo(get_raw=self.fastqueue.value) + logger.debug('Got photo at %s' % (datetime.datetime.now().strftime("%Y%m%d_%H:%M:%S.%f"))) if photo is None: - print("Photo failed") + logger.info("Photo failed") rec = None for r in self.record: @@ -131,31 +135,32 @@ def worker(self): rec = r break if rec is None: - print("WARNING: Failed to find associated photo record") + logger.info("WARNING: Failed to find associated photo record") photo_object = {'index': self.index.value, 'record': rec} if bool(self.return_full_colour.value): - if self.debug: print( + logger.debug( 'generating greyscale copy at %s' % (datetime.datetime.now().strftime("%Y%m%d_%H:%M:%S.%f"))) colorphoto = photo if photo is not None: + # ??? if self.fastqueue.value: # photo = downscale(np.mean(photo,2),10) photo = np.mean(photo[::10, ::10, :], 2) else: photo = np.mean(photo, 2) - if self.debug: print( + logger.debug( 'averaging completed at %s' % (datetime.datetime.now().strftime("%Y%m%d_%H:%M:%S.%f"))) photo = photo.astype(np.ubyte) - if self.debug: print( + logger.debug( 'type conversion completed at %s' % (datetime.datetime.now().strftime("%Y%m%d_%H:%M:%S.%f"))) if not self.fastqueue.value: colorphoto = colorphoto.astype(np.ubyte) - if self.debug: print( + logger.debug( 'colour type conv completed at%s' % (datetime.datetime.now().strftime("%Y%m%d_%H:%M:%S.%f"))) photo_object['colorimg'] = colorphoto - if self.debug: print( + logger.debug( 'greyscale copy completed at %s' % (datetime.datetime.now().strftime("%Y%m%d_%H:%M:%S.%f"))) else: if photo is not None: diff --git a/bee_track/camera_aravis.py b/bee_track/camera_aravis.py index 0c99ef9..23abcd7 100644 --- a/bee_track/camera_aravis.py +++ b/bee_track/camera_aravis.py @@ -1,6 +1,5 @@ """ -Aravis Camera interface -https://github.com/SintefManufacturing/python-aravis/blob/master/aravis.py +This module contains the Aravis Camera class. """ import logging @@ -9,6 +8,7 @@ import numpy as np # TODO what's this gi.repository? +# PyGObject https://gnome.pages.gitlab.gnome.org/pygobject/ from gi.repository import Aravis from bee_track.camera import Camera @@ -18,7 +18,9 @@ class AravisCamera(Camera): """ - TODO + Aravis Camera interface + + https://lazka.github.io/pgi-docs/Aravis-0.8/ """ @classmethod @@ -31,7 +33,7 @@ def update_device_list(cls): Aravis.update_device_list() @classmethod - def count_devices(cls) -> int: + def get_n_devices(cls) -> int: """ Retrieves the number of currently online devices. """ @@ -44,12 +46,12 @@ def count_devices(cls) -> int: return n_cams @classmethod - def get_camera_ids(cls) -> list[str]: + def get_device_ids(cls) -> list[str]: """ Get camera identifiers """ ids = list() - for i in range(cls.count_devices()): + for i in range(cls.get_n_devices()): # https://lazka.github.io/pgi-docs/Aravis-0.8/functions.html#Aravis.get_device_id dev_id: str = Aravis.get_device_id(i) logger.info("Found camera: %s" % dev_id) @@ -60,6 +62,7 @@ def setup_camera(self): print("PROCESS ID: ", os.getpid()) os.system("sudo chrt -f -p 1 %d" % os.getpid()) Aravis.enable_interface("Fake") + # https://lazka.github.io/pgi-docs/Aravis-0.8/classes/Camera.html self.aravis_camera = Aravis.Camera.new(self.cam_id) self.width = int(2048) self.height = int(1536) diff --git a/bee_track/core.py b/bee_track/core.py index 6872873..244f917 100644 --- a/bee_track/core.py +++ b/bee_track/core.py @@ -266,7 +266,7 @@ def startup(): multiprocessing.Process(target=trigger.worker).start() # Initialise cameras: get list of Aravis cameras - cam_ids = AravisCamera.get_camera_ids() + cam_ids = AravisCamera.get_device_ids() if not cam_ids: raise bee_track.camera.NoCamerasFoundError