Categories
Weekend Builds

Weekend Build – Wifi Enabled Bird Table (Part 1)

My partner is a big fan of birds. Not a serious ornithologist by any means, but she does like to ogle some tits when she gets chance.

I thought it would be a cool project to turn our run-of-the-mill bird table into something a bit smarter. My plan was to add a motion activated camera and get the system to email us snaps whenever a bird came to visit.

It ended up becoming more complex than I initially envisaged, and involved tinkering over many weekends, I’m quite happy with the result:

It seemed like a perfect project for a Raspberry PI. A compact mini-pc that has native camera support and digital pins for hooking up all sorts of sensors.

At first as a demo I grabbed a spare Raspberry Pi 2 and one of the gen 1 camera modules. I tested the camera using the raspivid app on a fresh installation of Raspbian. The field of view for the camera was too narrow, so it would be no good for close quarters use.

I found another camera unit, this one had a detechable fisheye lens which would be perfect.

Luckily in the roof of the bird table there was plenty of space to add the components needed. I stuffed the Pi into a weatherproof box and cut a slit for the camera ribbon cable. For now I would just run the 5v power out of the window and straight into the Pi, later I plan to add solar panels.

I needed some way of mounting the camera to the bird table, and also shield it from the elements (and wildlife). I desgined and printed an enclosure in PLA plastic.

It took a few iterations to get the design and dimensions just right.

The camera fit snugly and with a bit of light chiselling I was able to attach the bracket to the post of the bird table.

I decided on using a PIR motion sensor in the roof of the table to trigger the camera. This sensor would face directly down, so in theory should only trigger if a bird actually lands for a feed.

My first bit of demo code, using the excellent picamera library, looked like this:

import time
import picamera
import smtplib
import datetime
import RPi.GPIO as GPIO
from array import *
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText  # Added
from email.mime.image import MIMEImage

GPIO.setmode(GPIO.BCM)
GPIO.setup(18, GPIO.IN)
camera = picamera.PiCamera()
camera.led = False
camera.resolution = (800, 600)
camera.vflip = True

attach = [1,2,3,4,5]

print("BirtAlert ready")

while True:
    time.sleep(0.5)
    if GPIO.input(18):
        print("Motion detected!")
        i = 0
        while i < 4:
            i = i + 1
            print("Taking picture " + str(i))
            camera.annotate_background = picamera.Color('black')
            camera.annotate_text = datetime.datetime.now().strftime('%d-%m-%y %H:%M:%S')
            attach[i] = '/var/bcam/test' + str(i) + '.jpg'
            camera.capture(attach[i])
            time.sleep(0.2)

        print("Composing email")
        msg = MIMEMultipart()
        msg["To"] = 'email@address.com'
        msg["From"] = 'bird-alert@address.com'
        msg["Subject"] = 'Bird Alert!'

        msgText = MIMEText('<b>Bird Alert!</b><br><img src="cid:%s"><img src="cid:%s"><br><img src="cid:%s"><img src="cid:%s">' % (attach[1],attach[2],attach[3],attach[4]), 'html')  
        msg.attach(msgText)
        i = 0
        while i < 4:
            i = i + 1
            fp = open(attach[i], 'rb')                                                    
            img = MIMEImage(fp.read())
            fp.close()
            img.add_header('Content-ID', '<{}>'.format(attach[i]))
            msg.attach(img)

        email = smtplib.SMTP('1.2.3.4', 25)
        email.set_debuglevel(0)
        print("Sending email")
        email.sendmail(msg["From"], msg["To"], msg.as_string())

        email.quit()
        print("Pausing for 10 seconds")
        time.sleep(10)

This Python script would run on a loop looking for input from the PIR sensor. As soon as the sensor triggered it would snag 5 photos 200ms apart. It would then put them into an email and send it to me.

I got a couple of great shots, but on the first weekend after leaving the house the system went crazy and sent me over 200 emails, leading to Gmail termporarily blocking my account.

Clearly using a PIR sensor alone, outside in the daylight, was not reliable enough. I needed to cut the number of false alerts down.

I thought of two options to ensure I only received alerts for valid bird activity:

  • Using image comparison to detect the differences between the images (small changes means no activity)
  • Using machine learning to process the image and detect if it contains a bird (the Pi was likely not powerful to do this alone)

I took the path of least resistance, and looked into the options for image processing. Then I realised that if I used the camera’s native h264 encoder for video I could hook into this to determine the number of changes between frames.

The white dots represent motion in this scene from The Matrix, their size is proportional to the motion

A compression algorithm like h264 works by comparing the difference between two images (or frames) and then working out what moved and to where. This way it doesn’t have to save another copy of the (mostly similar) frame.

The number of changes can be directly linked to the motion occurring between those points in time. Since my camera is fixed, any significant motion over more than a couple of frames must be due to a bird (or a plane, or superman).

import time
import picamera
import picamera.array
import numpy as np
import datetime
import RPi.GPIO as GPIO
import io
import os
import subprocess
import PIL
from shutil import copyfile
from PIL import Image
       
def resizeimage( fname ):
    basewidth = 320
    img = Image.open(fname)
    wpercent = (basewidth / float(img.size[0]))
    hsize = int((float(img.size[1]) * float(wpercent)))
    img = img.resize((basewidth, hsize), PIL.Image.ANTIALIAS)
    img.save(fname)
    return
    
class DetectMotion(picamera.array.PiMotionAnalysis):
    def analyse(self, a):
        global vid_motion
        global motion_detected
        a = np.sqrt(
            np.square(a['x'].astype(np.float)) +
            np.square(a['y'].astype(np.float))
            ).clip(0, 255).astype(np.uint8)
        vid_motion = (a > 60).sum()
        if vid_motion > 10:
            motion_detected = True
        
      
       
GPIO.setmode(GPIO.BCM)
GPIO.setup(18, GPIO.IN)
camera = picamera.PiCamera()
camera.led = False
camera.resolution = (1280, 720)
camera.framerate = 25
#camera.hflip = True
camera.vflip = True
camera.rotation = 180

camera.annotate_background = picamera.Color('black')
camera.annotate_text = datetime.datetime.now().strftime('%d-%m-%y %H:%M:%S')
stream = picamera.PiCameraCircularIO(camera, seconds=70)
with DetectMotion(camera) as output:
    camera.start_recording(stream, format='h264', motion_output=output)
    print("BirdAlert ready")
    try:
        while True:
            motion_detected = False
            camera.annotate_text = datetime.datetime.now().strftime('%d-%m-%y %H:%M:%S')
            camera.wait_recording(0.2)

            if GPIO.input(18) and motion_detected == True:
                print("Motion detected!")
                start = datetime.datetime.now()
                filename = start.strftime("%d.%m.%y_%H-%M-%S")
                pic1 = io.BytesIO()
                pic2 = io.BytesIO()
                pic3 = io.BytesIO()
                pic4 = io.BytesIO()
				
                lasttrigger = 1;
                camera.capture(pic1, format='jpeg', use_video_port=True)  

                print("Clearing folder")
                os.system('rm /ram/*')
                
                print("Recording for 15 seconds")
                while (datetime.datetime.now() - start).seconds < 15:
                    if (datetime.datetime.now() - start).seconds == 4 and lasttrigger == 1:
                        camera.capture(pic2, format='jpeg', use_video_port=True)
                        lasttrigger = 2
                    if (datetime.datetime.now() - start).seconds == 9 and lasttrigger == 2:
                        camera.capture(pic3, format='jpeg', use_video_port=True)
                        lasttrigger = 3
                    if (datetime.datetime.now() - start).seconds == 10:
                        motion_detected = False
                    if (datetime.datetime.now() - start).seconds == 14 and lasttrigger == 3:
                        camera.capture(pic4, format='jpeg', use_video_port=True)
                        lasttrigger = 4
                    camera.annotate_text = datetime.datetime.now().strftime('%d-%m-%y %H:%M:%S') + '\n' + 'Motion: ' + str(vid_motion) + '  PIR: ' + str(GPIO.input(18))
                    camera.wait_recording(0.2)
                          
                if GPIO.input(18) and motion_detected == True:
                    print("Motion Still Detected! recording for another 15")
                    while (datetime.datetime.now() - start).seconds < 30:
                        camera.annotate_text = datetime.datetime.now().strftime('%d-%m-%y %H:%M:%S') + '\n' + 'Motion: ' + str(vid_motion) + '  PIR: ' + str(GPIO.input(18))
                        camera.wait_recording(0.2)
                        if (datetime.datetime.now() - start).seconds == 20:
                            motion_detected = False
                             
  
                print("Done recording")
                
                print("Clearing folder")
                os.system('rm /ram/*')
                
                print("Writing stills to /ram/")
                pic1.truncate()
                pic1.seek(0)
                with io.open('/ram/' + filename + "_1.jpg", 'wb') as output2:
                    output2.write(pic1.read())
                    
                pic2.truncate()
                pic2.seek(0)
                with io.open('/ram/' + filename + "_2.jpg", 'wb') as output2:
                    output2.write(pic2.read())

                pic3.truncate()
                pic3.seek(0)
                with io.open('/ram/' + filename + "_3.jpg", 'wb') as output2:
                    output2.write(pic3.read())

                pic4.truncate()
                pic4.seek(0)
                with io.open('/ram/' + filename + "_4.jpg", 'wb') as output2:
                    output2.write(pic4.read())

                takelast = ((datetime.datetime.now() - start).seconds + 5) * 25
              
                with stream.lock:
                    frame_count = 0
                    for frame in stream.frames:
                         frame_count += 1
                    
                    skipfirst = frame_count - takelast
                    if skipfirst < 0:
                        skipfirst = 0
                    print("Total frames: " + str(frame_count))
                    print("Interesting video: " + str(takelast))
                    print("Skipping the first: " + str(skipfirst))
                    
                    frame_count = 0
                    trimmed = 0
                    for frame in stream.frames:
                        frame_count += 1
                        
                        if frame.frame_type == picamera.PiVideoFrameType.sps_header:
                            if trimmed == 0:
                                stream.seek(frame.position)
                                trimmed = 1
                            else:
                                if frame_count <= skipfirst:
                                    stream.seek(frame.position)
                                

                    print("Writing video to /ram/" + filename + ".h264")
                    with io.open('/ram/' + filename + ".h264", 'wb') as output2:
                        output2.write(stream.read())
                    
                    stream.seek(0, 2)                
                
                camera.wait_recording(0.2)
                
                print ("Adding MP4 container")
                command = "MP4Box -add /ram/" + filename + ".h264 -fps 25 /ram/" + filename + ".mp4"

                process = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE)
                process.wait()
                
                camera.wait_recording(0.2)
                
                print("Resizing images...")
                resizeimage("/ram/" + filename + "_1.jpg")
                resizeimage("/ram/" + filename + "_2.jpg")
                resizeimage("/ram/" + filename + "_3.jpg")
                resizeimage("/ram/" + filename + "_4.jpg")
                
                camera.wait_recording(0.2)
                print("Copying files to network")
                copyfile("/ram/" + filename + "_1.jpg", "/media/networkshare/birds/" + filename + "_1.jpg")
                copyfile("/ram/" + filename + "_2.jpg", "/media/networkshare/birds/" + filename + "_2.jpg")
                copyfile("/ram/" + filename + "_3.jpg", "/media/networkshare/birds/" + filename + "_3.jpg")
                copyfile("/ram/" + filename + "_4.jpg", "/media/networkshare/birds/" + filename + "_4.jpg")
                copyfile("/ram/" + filename + ".mp4", "/media/networkshare/birds/" + filename + ".mp4")
                
				print("Done copying.  ")
               
                print("Recording for 5 seconds")
                waitstart = datetime.datetime.now()
                while (datetime.datetime.now() - waitstart).seconds < 5:
                    camera.annotate_text = datetime.datetime.now().strftime('%d-%m-%y %H:%M:%S')
                    camera.wait_recording(0.2)

                print("Ready!")

    finally:
        print ("BirdAlert ending")
        camera.stop_recording()
 

        

My new script continously captures video to a buffer, and the motion is quantified. After some experimenting a value of 60 worked well as a trigger threshold.

My best bird impression

So the program flow is now as follows:

  • Start capturing video to a circular buffer
  • If motion is detected in the video check if the PIR is also triggered
  • If the PIR is triggered capture 15 seconds of video and 4 images (staggered)
  • If the PIR is still triggered and there is motion, record for another 15 seconds (this could be placed on a loop for indefinite recording)
  • Because we have been recording to a circular buffer we also have access to video before the event that triggered the PIR. This is usually quite interesting, so we can include this.
  • We then save the video to a ramdisk (to avoid wearing out the slower SD card), and then apply an MP4 container to it so it can be played easily
  • The MP4 video and images are then saved to a shared folder on the network

This has proven to work remarkably well…

A pair of fighting jackdaws

That is, until Cyril arrived. Cyril is a squirrel. Cyril is a dick…

Cyril didn’t understand that it was a bird table, not a squirrel table. He would spend hours eating all the food and scaring off the birds.

After a few weeks with nothing but videos like this…

…it was time for action. I used a staple gun to attach some wire mesh to the outside of the bird table. Hopefully, this would be big enough to allow some birds in, but not the now portly Cyril, fat on stolen nuts.

It now looked more like a bird prison

This didn’t last. Within days Cyril had chewed his way under the bars…

This wasn’t even the worst of it

So i decided to reinforce the wood with some metal edging.

Now it really did look like a prison

But it was a lost cause, in a scene reminiscent of The Ring, the damn squirrel had squeezed himself through one of the holes.

I removed the bars and accepted defeat. For now the squirrel has beaten me.

We still do get birds stopping by, but we are more reserved with the food we put out.

To expand this in the future I plan to add solar panels to the roof, a battery and charge controller, and potentially investigate machine learning to classify types of creatures. That way if Cyril makes an appearance I could trigger a loud noise or vibration to scare him off.