Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 79 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
```
2026-03-19
This is an updated version of the SPIRO project for Raspberry PI 4, Ubuntu Bookworm and 16MP Camera
This is an updated version of the SPIRO project for Raspberry Pi 4, Raspberry Pi OS Bookworm 32-bit, the Waveshare Stepper Motor HAT, and a 16MP camera
Original Repo and Author: https://github.com/jonasoh/spiro
```
Expand Down Expand Up @@ -47,6 +47,18 @@ It is also possible to use a [Raspberry Pi 4B](https://www.raspberrypi.com/produ

The system basically consists of a camera, a green LED illuminator for imaging in the dark, and a motor-controlled imaging stage, as shown below.

The current hardware-control code assumes the stage motor is connected to **M2** on the [Waveshare Stepper Motor HAT](https://www.waveshare.com/wiki/Stepper_Motor_HAT), which uses a DRV8825 driver. SPIRO drives M2 through these BCM pins:

* `DIR`: BCM 24
* `STEP`: BCM 18
* `ENABLE`: BCM 4

The illumination LED remains on BCM 17. Because BCM 4 is consumed by the Waveshare M2 enable input, the default SPIRO configuration expects the positional microswitch on BCM 12. If your switch is wired elsewhere, update the persistent `sensor` setting accordingly.

The code now assumes the Waveshare HAT is left at its **factory-default** DIP setting, which is 1/32 microstepping. On M2 that means the `D3-D5` switches stay in their shipped position. SPIRO preserves the older calibration scale by internally expanding each logical software step into 16 STEP pulses. If you change the DIP switches away from the factory default, all saved calibration and jog values will change proportionally and must be recalibrated.

The Waveshare HAT also requires its own motor supply. Use the HAT's external `VIN` input with an appropriate supply for your motor, typically 9V to 12V, and set the DRV8825 current limit with the onboard potentiometer before long runs.

![SPIRO close-up](https://user-images.githubusercontent.com/6480370/60957134-6a4ae280-a304-11e9-8a03-0d854267297b.jpeg)

It is relatively cheap and easy to assemble multiple systems for running larger experiments.
Expand Down Expand Up @@ -75,7 +87,7 @@ https://user-images.githubusercontent.com/6480370/149747187-09aa5269-0595-4daf-8

## Installation

First, prepare the SD card with a fresh release of [Raspberry Pi OS Lite **(Legacy)**](https://www.raspberrypi.com/software/operating-systems/#raspberry-pi-os-legacy) (follow the official [instructions](https://www.raspberrypi.org/documentation/installation/installing-images/README.md)). **NB! Due to a change in the camera stack, only "Legacy" Raspberry Pi OS versions are supported for now.**
First, prepare the SD card with a fresh release of **Raspberry Pi OS Lite (32-bit) Bookworm** for Raspberry Pi 4 (follow the official [instructions](https://www.raspberrypi.org/documentation/installation/installing-images/README.md)). This codebase targets the Bookworm libcamera stack and the Waveshare Stepper Motor HAT.

**Note**: If using the [ArduCam drop-in replacement camera module](https://www.arducam.com/product/arducam-imx219-auto-focus-camera-module-drop-in-replacement-for-raspberry-pi-v2-and-nvidia-jetson-nano-camera/), you need to add the following line to the file config.txt on the newly prepared SD card:
```
Expand All @@ -96,7 +108,7 @@ sudo raspi-config
In the raspi-config interface, make the following changes:
* **Change the password**. The system will allow network access, and a weak password **will** compromise your network security **and** your experimental data.
* After changing the password, connect the network cable (if you are using wired networking).
* Under *Interfacing*, enable *Camera*, *I2C*, and *SSH*.
* Under *Interfacing*, enable *I2C* and *SSH*. The camera is used through Bookworm's libcamera stack, so the old Legacy camera stack is not required.
* In *Performance Options*, set *GPU Memory* to 256.
* Under *Localisation Options*, make sure to set the *Timezone*. Please note that a working network connection is required to maintain the correct date.
* If needed, configure *Network* and *Localization* options here as well. Set a *Hostname* under Network if you plan on running several SPIROs.
Expand All @@ -108,10 +120,30 @@ Next, ensure the system is connected to the internet. Update the operating syste
```
sudo apt update
sudo apt upgrade -y
sudo apt install -y git zip i2c-tools libatlas-base-dev python3-pip python3-pil
sudo apt install -y git zip i2c-tools libatlas-base-dev python3-pip python3-pil python3-gpiozero python3-lgpio
sudo apt install -y python3-av python3-numpy python3-pil libcap-dev libavformat-dev libavcodec-dev libavdevice-dev libavutil-dev libavfilter-dev libswscale-dev libswresample-dev pkg-config
```

Before assembling the stage, set the Waveshare HAT to the expected SPIRO defaults:

* Connect the stage motor to **M2**.
* Set the HAT's motor power switch on.
* Provide an external motor supply to the HAT `VIN` input.
* Set the M2 `D3-D5` DIP switches for the desired microstepping mode. For more
torque (recommended when the motor is skipping or lacks power), set M2 to
**1/8 microstepping** by configuring the switches for M2 as: `D3=ON`,
`D4=ON`, `D5=OFF` (i.e. MODE0=1, MODE1=1, MODE2=0). Always power OFF the
HAT (turn motor VIN off) before changing DIP switches to avoid damage.
After changing DIP switches, restore motor power and test motion slowly.

Note: SPIRO preserves a legacy logical-step scale by emitting multiple
microstep pulses per logical step. The code default maps one logical step to
4 microstep pulses when using 1/8 microstepping. If you change the microstep
mode you must recalibrate the stage because saved calibration and jog
values scale with the microstepping setting.

* If you are using the original SPIRO stop-switch wiring on BCM 4, move that switch to another free GPIO such as BCM 12 because BCM 4 is now required by the M2 enable pin.

Then, install the SPIRO software and its dependencies:

```
Expand Down Expand Up @@ -229,7 +261,9 @@ After logging in to the system, you are presented with the *Live view*. Here, yo

Under *Day image settings* and *Night image settings*, you can adjust the exposure time for day and night images in real time. For night images, make sure that representative conditions are used (i.e., turn off the lights in the growth chamber). When the image is captured according to your liking, choose *Update and save*.

For locating the initial imaging position, the system turns the cube until a positional switch is activated. It then turns the cube a predefined amount of steps (*calibration value*). The check that the calibration value is correct, go to the *Start position calibration* view. Here, you may try out the current value, as well as change it. Make sure to click *Save value* if you change it.
For locating the imaging positions, SPIRO expects **two sensor events per position**. The first trigger is treated as the early-warning stop, telling the software that the final stop is approaching. The system then slows down, finds the second stop, and only after that applies the configured final offset (*calibration value*). That same final offset is used for all positions, not only the start position.

Use the *Calibrate motor* view to tune the primary step delay, secondary step delay, secondary stop gap, stable sensor reads, and final offset. The page now also includes manual jog controls, live step counters, and repeatability tests so you can dial in settings without saving every trial.

### Starting an experiment

Expand Down Expand Up @@ -319,28 +353,54 @@ journalctl --user-unit=spiro

### Testing the LED and motor

To check whether the Raspberry Pi can control the LED illuminator, first set the LED control pin to output mode:
To check whether the Raspberry Pi can control the LED illuminator on Bookworm, use gpiozero instead of the deprecated sysfs GPIO interface:

```
echo 17 > /sys/class/gpio/export
echo "out" > /sys/class/gpio/gpio17/direction
```python
python3 - <<'PY'
from gpiozero import LED

led = LED(17)
led.on()
input('LED is on. Press Enter to turn it off...')
led.off()
led.close()
PY
```

You may then toggle it on or off using the commands
To test the Waveshare HAT on **M2**, make sure the HAT has external motor power connected, the motor is plugged into **M2**, and the DIP switches are left at the factory default microstepping setting. Then run:

```
echo 1 > /sys/class/gpio/gpio17/value # Turn LED on
```
```python
python3 - <<'PY'
from time import sleep
from gpiozero import DigitalOutputDevice

```
echo 0 > /sys/class/gpio/gpio17/value # Turn LED off
```
enable = DigitalOutputDevice(4, active_high=True, initial_value=False)
direction = DigitalOutputDevice(24, initial_value=False)
step = DigitalOutputDevice(18, initial_value=False)

If it doesn't respond to this command, this may indicate either miswiring, or that either the LED strip or the MOSFET is non-functional.
enable.on()
direction.on()
for _ in range(50):
step.on()
sleep(0.001)
step.off()
sleep(0.02)
enable.off()

step.close()
direction.close()
enable.close()
PY
```

Similarly, you can turn on and off the motor, by substituting the value *23* for 17 in the above examples. When GPIO pin 23 is toggled on, the cube should be locked in position. If it is not, check that your wiring looks good, that the power supply is connected, and that the shaft coupler is firmly attached to both the cube and the motor.
If the motor does not move correctly:

If the motor is moving jerkily during normal operation, there is likely a problem with the wiring of the coil pins (Ain1&2 and Bin1&2).
* Confirm the motor is connected to **M2** on the HAT.
* Confirm the HAT's external motor power supply is connected and switched on.
* Confirm the M2 DIP switches `D3-D5` are still at the factory default unless you intentionally recalibrated for another microstepping mode.
* Confirm the motor wiring matches the HAT's `A3/A4/B3/B4` outputs.
* Confirm the DRV8825 current limit is set appropriately for the motor.
* If the stage moves in the wrong direction, set `motor_direction_active_high` in the SPIRO config instead of rewiring the whole system.

## Licensing

Expand Down
3 changes: 1 addition & 2 deletions bin/spiro
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
#!/usr/bin/env python3
#
# small shell script wrapper for spiro
"""Thin executable wrapper that forwards to the packaged SPIRO entry point."""

import spiro.spiro
spiro.spiro.main()
75 changes: 75 additions & 0 deletions bin/test_stepper_slow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""
bin/test_stepper_slow.py - simple slow stepper test for Waveshare HAT M2.
Defaults match SPIRO config: ENABLE=4, DIR=24, STEP=18.
Designed for manual testing on the Raspberry Pi. Use --inter-step to slow pulses.
"""

import argparse
from time import sleep

try:
from gpiozero import DigitalOutputDevice
except Exception:
# Fallback stub for environments without gpiozero (safe for static checks)
class DigitalOutputDevice:
def __init__(self, *args, **kwargs):
pass
def on(self):
pass
def off(self):
pass
def close(self):
pass


def main():
parser = argparse.ArgumentParser(description="Slow stepper test (Waveshare HAT M2)")
parser.add_argument('--enable-pin', type=int, default=4)
parser.add_argument('--dir-pin', type=int, default=24)
parser.add_argument('--step-pin', type=int, default=18)
parser.add_argument('--steps', type=int, default=200)
parser.add_argument('--step-high', type=float, default=0.005,
help='STEP pulse high time in seconds (>= ~2e-6)')
parser.add_argument('--inter-step', type=float, default=0.1,
help='Delay between steps in seconds')
parser.add_argument('--direction', type=int, default=1, choices=(0, 1))
parser.add_argument('--enable-active-low', action='store_true',
help='Set if HAT enable is active low (default active high)')
args = parser.parse_args()

enable_active_high = not args.enable_active_low

enable = DigitalOutputDevice(args.enable_pin, active_high=enable_active_high, initial_value=False)
direction = DigitalOutputDevice(args.dir_pin, initial_value=False)
step = DigitalOutputDevice(args.step_pin, initial_value=False)

try:
print(f"Enabling driver (pin {args.enable_pin})")
enable.on()
if args.direction:
direction.on()
else:
direction.off()

print(f"Stepping {args.steps} pulses (high={args.step_high}s, delay={args.inter_step}s)")
for i in range(args.steps):
step.on()
sleep(args.step_high)
step.off()
sleep(args.inter_step)
except KeyboardInterrupt:
print("Interrupted, disabling driver.")
finally:
enable.off()
try:
step.close()
direction.close()
enable.close()
except Exception:
pass


if __name__ == '__main__':
main()
18 changes: 13 additions & 5 deletions setup.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
"""Package metadata and installation configuration for SPIRO.
The setup script reads version information directly from the local version
helper instead of importing the full package. That keeps installation safe on
systems where Raspberry Pi hardware libraries are unavailable at build time.
"""

from setuptools import setup, find_packages

# from miniver
def get_version_and_cmdclass(package_path):
"""Load version.py module without importing the whole package.
"""Load the version helper without importing the full package tree.
Template code from miniver
SPIRO imports hardware-related modules at runtime, so setup must avoid a
normal package import here.
"""
import os
from importlib.util import module_from_spec, spec_from_file_location
Expand All @@ -16,15 +23,16 @@ def get_version_and_cmdclass(package_path):


version, cmdclass = get_version_and_cmdclass("spiro")
#version = "0.1.0"
# Preserve the current packaging behavior by leaving custom build command
# overrides disabled unless they are intentionally re-enabled later.
cmdclass = {}

setup(name = 'spiro',
version = version,
cmdclass = cmdclass,
packages = find_packages(),
scripts = ['bin/spiro'],
install_requires = ['picamera2==0.3.31', 'RPi.GPIO==0.7.1', 'Flask==2.2.5', 'waitress==2.1.2', 'numpy==1.24.2', 'Werkzeug==2.2.3'],
install_requires = ['picamera2==0.3.31', 'gpiozero>=2.0.1,<3', 'Flask==2.2.5', 'waitress==2.1.2', 'numpy==1.24.2', 'Werkzeug==2.2.3'],
author = 'Kirby Kalbaugh',
author_email = 'kkalbaug@purdue.edu',
description = 'Control software for the SPIRO biological imaging system',
Expand Down
6 changes: 6 additions & 0 deletions spiro/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
"""SPIRO package namespace.
Only lightweight package metadata is exposed here so callers can query the
installed version without importing the camera or GPIO subsystems.
"""

from ._version import __version__
8 changes: 8 additions & 0 deletions spiro/_static_version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
# -*- coding: utf-8 -*-
"""Static version markers used by the bundled miniver helper.
In a live git checkout this file usually keeps the ``__use_git__`` sentinel so
the runtime version can be derived from tags. When a source or wheel
distribution is built, setup rewrites the file with a fixed version string so
the installed package no longer depends on git metadata being present.
"""

# This file is part of 'miniver': https://github.com/jbweston/miniver
#
# This file will be overwritten by setup.py when a source or binary
Expand Down
21 changes: 21 additions & 0 deletions spiro/_version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
# -*- coding: utf-8 -*-
"""Version resolution helpers vendored from miniver.
SPIRO uses these utilities to produce a stable package version from git tags in
development and from frozen metadata in release artifacts.
"""

# This file is part of 'miniver': https://github.com/jbweston/miniver
#
from collections import namedtuple
Expand Down Expand Up @@ -28,6 +34,11 @@


def get_version(version_file=STATIC_VERSION_FILE):
"""Resolve the current package version as a string.
The lookup order is: static version file, live git metadata, archive
placeholders, and finally an ``unknown`` fallback.
"""
version_info = get_static_version_info(version_file)
version = version_info["version"]
if version == "__use_git__":
Expand All @@ -42,17 +53,20 @@ def get_version(version_file=STATIC_VERSION_FILE):


def get_static_version_info(version_file=STATIC_VERSION_FILE):
"""Load the static version helper into an isolated namespace."""
version_info = {}
with open(os.path.join(package_root, version_file), "rb") as f:
exec(f.read(), {}, version_info)
return version_info


def version_is_from_git(version_file=STATIC_VERSION_FILE):
"""Return whether version resolution is delegated to git metadata."""
return get_static_version_info(version_file)["version"] == "__use_git__"


def pep440_format(version_info):
"""Convert a ``Version`` tuple into a PEP 440 compliant version string."""
release, dev, labels = version_info

version_parts = [release]
Expand All @@ -70,6 +84,7 @@ def pep440_format(version_info):


def get_version_from_git():
"""Derive a version from ``git describe`` when running from a checkout."""
try:
p = subprocess.Popen(
["git", "rev-parse", "--show-toplevel"],
Expand Down Expand Up @@ -145,6 +160,7 @@ def get_version_from_git():
# pointing to, or its hash (with no version info)
# if it is not tagged.
def get_version_from_git_archive(version_info):
"""Derive a version from ``git archive`` placeholders when available."""
try:
refnames = version_info["refnames"]
git_hash = version_info["git_hash"]
Expand Down Expand Up @@ -176,6 +192,7 @@ def get_version_from_git_archive(version_info):


def _write_version(fname):
"""Write the resolved version into the generated static version file."""
# This could be a hard link, so try to delete it first. Is there any way
# to do this atomically together with opening?
try:
Expand All @@ -190,12 +207,16 @@ def _write_version(fname):


class _build_py(build_py_orig):
"""Build hook that injects a fixed static version into build outputs."""

def run(self):
super().run()
_write_version(os.path.join(self.build_lib, package_name, STATIC_VERSION_FILE))


class _sdist(sdist_orig):
"""sdist hook that writes frozen version metadata into release archives."""

def make_release_tree(self, base_dir, files):
super().make_release_tree(base_dir, files)
if _package_root_inside_src:
Expand Down
Loading