Interactive Scuplture Pi

10488392_10152745164353673_236725446486368080_n

So I have finally got round to blogging about the Artwork I computerised with a Raspberry Pi in 2013; just three years late then.  This is in small part due to my not having a blog to blog on and in large to me just not getting it done; no real excuse then as the not having a blog is very easily resolved.

Background

The picture above is of the finished work by artist Jane Clatworthy.  You can see some of her work on the Saatchi Art site here and also on Carbonmade here.  The work was partly Lottery funded and commissioned by the Roses Theatre in Tewkesbury.  As part of Gloucestershire’s Your Future project the work concentrates on experiences of domestic violence; though that is incidental to the use of the Raspberry Pi.  Jane produced an interactive sculpture in the form of a traditional fairground machine with scales; weights are placed on one side or the other based on the result of spinning a wheel of fortune.  When one side of the scale is down a recording of positive experiences was to be played; progressively louder as more weights were added.  And when the other side of the scale is down negative experiences; again louder as more weights are added.

Jane’s issue was how to construct something that would achieve the playback and mechanical possibilities involving, for example, two mp3 or cassette players were being considered – but how to change the volume, how to only have positive or negative playing back, how to rewind the cassette, how to do the mechanical linkage etc.?  This is when I got involved.

I suggested we experiment with the use of a Raspberry Pi as that should remove most of the mechanical and electro-mechanical issues; hopefully making it much simpler, much cheaper and more reliable to implement.

My initial thoughts were to have an arm connected to the pivot point of the scales holding a magnet that moved over an array of reed switches.  That got prototyped but seemed clumsy and still left a mechanical linkage to contrive.  Discussing this one lunchtime with my colleague Alaric he suggested using an ultrasound distance measurer.

Enter the HC-SR04

A quick look brought up the HC-SR04 and Matt’s excellent write up here.  From his blog I took some code as an initial framework and started playing, wiring it as below:

Screen Shot 2016-04-21 at 19.09.31Once working the decision was how to mount it inside the cabinet.  We drilled holes in the cabinet below the scales, attached rods to the centre of the scale baskets which went into the cabinet and then on the end of the rods mounted horizontally a couple of round wooden discs.  I’d originally though of having two HC-SR04s but soon realised that was OTT (and what if they ‘voted’ differently and both insisted the scaled were down on their side) and one on just one side would do fine.

So it looked like this (the dotted box is inside the cabinet):

Screen Shot 2016-05-25 at 10.26.39.png

The distance sensor on the left can tell (after calibration) whether the scales are balanced or if not, how far up or down they are.  The code works out the measurement in CM but for the purposes of runtime the actual physical distance is irrelevant.  What was needed was a measurement of the balance point, a hard coded window around that point and then the minimum and maximum the scales could be at.

Playback

Next was how to get the audio to work.  For this I eventually settled on the pygame.mixer.

With this I could set the Pi playing both the two tracks (positive experience and negative experience) and simply change the volume of positive increasing as the distance increased above balanced or the volume of negative increasing as the distance decreased below balanced.

I did have to do some playing at the command line to get the volume up as it was initially far too quiet:

amixer cset numid=3 100,100
sudo alsactl store
amixer cset numid=1 100%
sudo alsactl store

“3 100,100” sets both channels to 100%, “1 100%” sets the output to headphones at 100%.

Tuning, feedback and calibration

Once the system was working I did various experimentation with smoothing the volume changes so they were not too harsh.  In the end I used a fifo based on deque from collections and weighted the most recent readings more than the older.

Another addition to the software was for calibration.  I realised that hard coding the distance readings (fully down, fully up and balanced) was probably going to lead to my being called in to re-calibrate at some time in the future, or more likely every time the cabinet was moved.  To avoid this I added a button to put the Pi into calibration mode and some LEDs to walk through the calibration using ConfigParser to store and read the results.  The LEDs I also used at runtime to indicate whether the system thought the scales were up, down or balanced.  Calibration Instructions.

Screen Shot 2016-04-21 at 19.09.47

 

Assembly or “crude but functional”

So how to put it all together.  It needed to be easy to install and remove as a single item so the Pi and electronics were all attached to a plank of wood.  As it is a one off I decided not to go down the path of making a PCB or even using veroboard but to just wire it together using patch leads and terminal blocks.  OK, so it’s not neat and tidy but it does have the advantage over PCB/vero that if I decide to tweak the wiring once in-situ it’s a lot easier.  The whole thing could definitely be described as “crude but functional”.

Here is a pic of it all mounted ready to go into the cabinet.  I also drilled a large hole underneath the Pi CPU for ventilation.

Mounted.JPG

Auto start

A tweak to the crontab to make the script run on boot:

@reboot python /home/pi/player4.py &

Watchdog

So everything seems fine and the piece is installed in the first exhibition space and then I get a call from Jane saying it has stopped working and the LEDs are not flashing any more. Grab the diagnostics file and reboot and all is OK.  A quick look at the diagnostics log file and I realise that the code has got stuck waiting for the HC-SR04 to reply.  I guess that what has happened is that the Pi, running Linux which is a non-deterministic OS, has missed the reply from the HC-SR04.  Presumably the kernel was off doing something else.  I recreated the issue and then looked for a solution eventually finding this great python watchdog timer module.  After adding that in around the call to my measure() function no more system halts.

# Wrap the call to measure in case it doesn't trigger the HC-SR04 correctly
try:
  with Watchdog(2):
    distance = measure()
except Watchdog:
  distance = BALANCE_TOO_HIGH
  logger.warning('Watchdog timeout')

References

DZone – Simple Python Watchdog Timer

Matt @ Raspberry Pi Spy – Ultrasonic Distance Measurement Using Python

The code …

So finally the code is here, if any of it is any use to you feel free to take it in whole or in part and do what you wish :

#!/usr/bin/python

# -----------------------
# Import required Python libraries
# -----------------------
import time
import RPi.GPIO as GPIO
import pygame.mixer as pgm
from collections import deque
import ConfigParser
from watchdog import Watchdog
import logging

# -----------------------
# Define some constants
# -----------------------

config_file = "/home/pi/config.ini"

c1_file = "/home/pi/PositivewithFX.wav"
c2_file = "/home/pi/NegativewithFX.wav"

# Define GPIO pins to use on Pi
GPIO_TRIGGER = 23
GPIO_ECHO    = 24

GPIO_LEDA    = 22
GPIO_LEDB    = 27
GPIO_LEDC    = 25

GPIO_CALIBRATE = 4

BALANCE_MID = 10.6
BALANCE_LOW = 5.6
BALANCE_HIGH = 16.6

BALANCE_MARGIN = 0.5

BALANCE_MID_HIGH = BALANCE_MID + BALANCE_MARGIN
BALANCE_MID_LOW = BALANCE_MID - BALANCE_MARGIN
BALANCE_DOWN_DIST = BALANCE_MID - BALANCE_LOW
BALANCE_UP_DIST = BALANCE_HIGH - BALANCE_MID

BALANCE_TOO_HIGH = BALANCE_HIGH + 2.0


MASK_LEDA = 4
MASK_LEDB = 2
MASK_LEDC = 1

MASK_ALL = MASK_LEDA + MASK_LEDB + MASK_LEDC


# -----------------------
# Define some functions
# -----------------------

def measure():
  # This function measures a distance
  GPIO.output(GPIO_TRIGGER, True)
  time.sleep(0.00001)
  GPIO.output(GPIO_TRIGGER, False)
  start = time.time()

  while GPIO.input(GPIO_ECHO)==0:
    pass
  start = time.time()

  while GPIO.input(GPIO_ECHO)==1:
    pass
  stop = time.time()

  elapsed = stop-start
  distance = (elapsed * 34300)/2

  return distance


def measure_average():
  # This function takes 3 measurements and
  # returns the average.
  distance1=measure()
  time.sleep(0.1)
  distance2=measure()
  time.sleep(0.1)
  distance3=measure()
  distance = distance1 + distance2 + distance3
  distance = distance / 3
  return distance


def readconfig():
  global BALANCE_LOW
  global BALANCE_MID
  global BALANCE_HIGH
  global BALANCE_MID_HIGH
  global BALANCE_MID_LOW
  global BALANCE_DOWN_DIST
  global BALANCE_UP_DIST
  global BALANCE_TOO_HIGH

  confreader = ConfigParser.SafeConfigParser()

  confreader.read(config_file)

  BALANCE_LOW  = confreader.getfloat('settings', 'min')
  BALANCE_MID  = confreader.getfloat('settings', 'balance')
  BALANCE_HIGH = confreader.getfloat('settings', 'max')

  BALANCE_MID_HIGH = BALANCE_MID + BALANCE_MARGIN
  BALANCE_MID_LOW = BALANCE_MID - BALANCE_MARGIN
  BALANCE_DOWN_DIST = BALANCE_MID - BALANCE_LOW
  BALANCE_UP_DIST = BALANCE_HIGH - BALANCE_MID
  
  BALANCE_TOO_HIGH = BALANCE_HIGH + 2.0

  msg = 'Read calibration file: LOW[%0.3f] MID[%0.3f] HIGH[%0.3f]' % (BALANCE_LOW, BALANCE_MID, BALANCE_HIGH)
  logger.info(msg)
  msg = '  calculated values: MID_HIGH[%0.3f] MID_LOW[%0.3f] DOWN_DIST[%0.3f] UP_DIST[%0.3f] TOO_HIGH[%0.3f]' % (BALANCE_MID_HIGH, BALANCE_MID_LOW, BALANCE_DOWN_DIST, BALANCE_UP_DIST, BALANCE_TOO_HIGH)
  logger.info(msg)
  
  return


def writeconfig():
  confwriter = ConfigParser.SafeConfigParser()

  confwriter.add_section('settings')
  confwriter.set('settings', 'min', str(BALANCE_LOW))
  confwriter.set('settings', 'balance', str(BALANCE_MID))
  confwriter.set('settings', 'max', str(BALANCE_HIGH))

  #for section in confwriter.sections():
  #  print section
  #  for name, value in confwriter.items(section):
  #    print '  %s = %r' % (name, value)

  with open(config_file, 'wb') as configfile:
    confwriter.write(configfile)
  configfile.close()
  
  return
  

def tf(val, mask):
  return (val & MASK_ALL & mask) != 0


def setleds(l):
  GPIO.output(GPIO_LEDA, tf(l, MASK_LEDA))
  GPIO.output(GPIO_LEDB, tf(l, MASK_LEDB))
  GPIO.output(GPIO_LEDC, tf(l, MASK_LEDC))


def calibrate():
  global BALANCE_LOW
  global BALANCE_MID
  global BALANCE_HIGH

  logger.info("Calibration button detected")
  
  start = time.time()
  stop = start
  while GPIO.input(GPIO_CALIBRATE)==1:
    stop = time.time()
    
  elapsed = stop-start
  
  #print "Button pressed for %ds" % elapsed
  
  if elapsed <= 2:
    logger.warning("Calibration button not held for >2s, not calibrating")
  else:
    logger.info("Begin calibration")
    #centre LED steady, left & right flash
    leds = MASK_ALL
    while GPIO.input(GPIO_CALIBRATE)==0:
      leds = ~ leds
      leds = leds | MASK_LEDB
      setleds(leds)
      time.sleep(0.1)
    setleds(MASK_LEDB)
    while GPIO.input(GPIO_CALIBRATE)==1:
      time.sleep(0.1)

    # take 20 readings and average
    setleds(MASK_LEDB)
    total = 0.0
    for num in range(1,20):
      total += measure()
      time.sleep(0.2)
    BALANCE_MID = total / 20

    #left LED steady, centre and right flash
    leds = MASK_ALL
    while GPIO.input(GPIO_CALIBRATE)==0:
      leds = ~ leds
      leds = leds | MASK_LEDC
      setleds(leds)
      time.sleep(0.1)
    setleds(MASK_LEDC)
    while GPIO.input(GPIO_CALIBRATE)==1:
      time.sleep(0.1)
      
    # take 20 readings and average
    setleds(MASK_LEDC)
    total = 0.0
    for num in range(1,20):
      total += measure()
      time.sleep(0.2)
    BALANCE_HIGH = total / 20

    #right LED steady, left and centre flash
    leds = MASK_ALL
    while GPIO.input(GPIO_CALIBRATE)==0:
      leds = ~ leds
      leds = leds | MASK_LEDA
      setleds(leds)
      time.sleep(0.1)
    setleds(MASK_LEDA)
    while GPIO.input(GPIO_CALIBRATE)==1:
      time.sleep(0.1)

    # take 20 readings and average
    setleds(MASK_LEDA)
    total = 0.0
    for num in range(1,20):
      total += measure()
      time.sleep(0.2)
    BALANCE_LOW = total / 20

    if BALANCE_LOW > BALANCE_HIGH:
      BALANCE_LOW,BALANCE_HIGH = BALANCE_HIGH,BALANCE_LOW
               
    msg = "Calibration result = LOW[%.3f] MID[%.3f] HIGH[%.3f]" % (BALANCE_LOW, BALANCE_MID, BALANCE_HIGH)
    logger.info(msg)

    if (BALANCE_LOW < BALANCE_MID) and (BALANCE_MID < BALANCE_HIGH):
      writeconfig()
      setleds(MASK_ALL)
      time.sleep(5.0)
    
  return


# -----------------------
# Main Script
# -----------------------

logger = logging.getLogger('myapp')
hdlr = logging.FileHandler('/home/pi/player.log')
formatter = logging.Formatter('%(asctime)s %(levelname)s %(message)s')
hdlr.setFormatter(formatter)
logger.addHandler(hdlr) 
logger.setLevel(logging.INFO)

pgm.init()
pgm.set_num_channels(2)

c1_sound = pgm.Sound(c1_file)
c2_sound = pgm.Sound(c2_file)

c1_channel = c1_sound.play(-1)
c2_channel = c2_sound.play(-1)

c1_sound.set_volume(0.0)
c2_sound.set_volume(0.0)

c1_volume = 0.0
c2_volume = 0.0

# Use BCM GPIO references
# instead of physical pin numbers
GPIO.setmode(GPIO.BCM)

# Set pins as output and input
GPIO.setup(GPIO_TRIGGER,GPIO.OUT)  # Trigger
GPIO.setup(GPIO_ECHO,GPIO.IN)      # Echo

GPIO.setup(GPIO_LEDA,GPIO.OUT)     # LED
GPIO.setup(GPIO_LEDB,GPIO.OUT)
GPIO.setup(GPIO_LEDC,GPIO.OUT)

GPIO.setup(GPIO_CALIBRATE,GPIO.IN) # Calibrate button

# Set trigger to False (Low)
GPIO.output(GPIO_TRIGGER, False)

# Set LEDs to off
GPIO.output(GPIO_LEDA, False)
GPIO.output(GPIO_LEDB, False)
GPIO.output(GPIO_LEDC, False)

#writeconfig() #used only when no config file at all!

readconfig()

#distance_hist = [BALANCE_MID,BALANCE_MID,BALANCE_MID,BALANCE_MID,BALANCE_MID]
distance_hist = [BALANCE_MID,BALANCE_MID,BALANCE_MID]
dh = deque(distance_hist)

# Wrap main content in a try block so we can
# catch the user pressing CTRL-C and run the
# GPIO cleanup function. This will also prevent
# the user seeing lots of unnecessary error
# messages.
try:

  LEDMAP = MASK_ALL

  while True:
    if GPIO.input(GPIO_CALIBRATE)==1:
      c1_sound.set_volume(0.0)
      c2_sound.set_volume(0.0)
      calibrate()
      readconfig()

    # Wrap the call to measure in case it doesn't trigger the HC-SR04 correctly
    try:
      with Watchdog(2):
        distance = measure()
    except Watchdog:
      distance = BALANCE_TOO_HIGH
      logger.warning('Watchdog timeout')
      #print "watchdog"
    #distance = measure_average()
    #print distance
    
    # Check if the distance is wildly high - if so reject completely (means it's timed out or mis-read)
    if distance < BALANCE_TOO_HIGH:
      if distance < BALANCE_LOW:
        distance = BALANCE_LOW
      if distance > BALANCE_HIGH:
        distance = BALANCE_HIGH
          
      dh.pop()
      dh.appendleft(distance)
      #print dh
    
      #avgdist = (dh[0] + dh[1] + dh[2] + dh[3] + dh[4]) / 5
      #avgdist = ((dh[0] * 5) + (dh[1] * 4) + (dh[2] * 3) + (dh[3] * 2) + dh[4]) / 15
      avgdist = ((dh[0] * 3) + (dh[1] * 2) + (dh[2] * 1)) / 6
      #print avgdist
      avgdist = distance

      if ((avgdist >= BALANCE_MID_LOW) and (avgdist <= BALANCE_MID_HIGH)):
        # Balanced so silent
        c1_volume = 0.0
        c2_volume = 0.0
      elif avgdist < BALANCE_MID:
        # Down so c1
        c2_volume = 0.0
        distdown = BALANCE_MID - avgdist
        c1_volume = distdown / BALANCE_DOWN_DIST
      else:
        # Up so c2
        c1_volume = 0.0
        distup = avgdist - BALANCE_MID
        c2_volume = distup / BALANCE_UP_DIST
    
      #print "Volume c1:%.3f c2:%.3f" % (c1_volume, c2_volume)
      c1_sound.set_volume(c1_volume)
      c2_sound.set_volume(c2_volume)

      # Flash the LEDs except for the up/down/balanced LED which should be static on
      LEDMAP = ~ LEDMAP
      if c1_volume == c2_volume:
        LEDMAP = LEDMAP | MASK_LEDB
      elif c1_volume > 0.0:
        LEDMAP = LEDMAP | MASK_LEDA
      elif c2_volume > 0.0:
        LEDMAP = LEDMAP | MASK_LEDC
      setleds(LEDMAP)
    #else:
    #  msg = "Distance over limit [%.3f]" % (distance)
    #  logger.warning(msg)
    
    time.sleep(0.25)

except KeyboardInterrupt:
  # User pressed CTRL-C
  c1_sound.stop()
  c2_sound.stop()

  # Reset GPIO settings
  GPIO.cleanup()

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s