Operation Epic Furry: A Machine Learning based Raspberry Pi-Powered Pigeon Defense System

Published on: 2026-02-28 · #machine learning

Named after Operation Epic Fury, but our war is against pigeons and their tyranny.

If you’re an EE/ML wizard reading this, please enjoy the vibes and gently ignore the parts where I reinvented a wheel.

What?

My attempt at a Machine Learning based Raspberry Pi-powered pigeon shooer. Yes, shooer. Not shooter. Relax.

Brain

I trained a small YOLO model in Google Colab on pigeon pictures I found online. It can detect pigeons and draw bounding boxes around them.

I use a Raspberry Pi 5 with a Camera Module 3. The video stream is passed to the model and I run a detection loop. Once a pigeon is detected, the servos are called into action.

The model and camera are initialized at startup:

Python
UTF-8|2 Lines|
picam = detect.camera_init()
model = detect.model_init()  # model path is defined in config.py

The detection loop only runs inference when we’re not already tracking something, or once every 30 frames. Inference is expensive, so we lean on a KCF tracker in between.

Python
UTF-8|5 Lines|
run_inference = (not self.state.is_tracking) or (self.state.frame_count % config.INFERENCE_INTERVAL == 0)

if run_inference:
    results = self.model(frame, conf=config.CONFIDENCE_THRESHOLD, classes=[1], stream=False)
    # if a bird is found, hand it off to the KCF tracker

Arms

Movement happens with help of two MG996R servos. They need 5V 2A to function properly. For signal handling I use a PCA9685 PWM driver. The servo signals directly from the Pi were causing jitters. I use a Pi 5, so pigpio couldn’t be used.

Based on the input from the detection loop, the servos perform pan and tilt operations. This helps track the birds and center the frame before calling the relay. The servos are set up through the PCA9685 over I2C:

Python
UTF-8|6 Lines|
def setup_servo(self):
    i2c_bus = busio.I2C(board.SCL, board.SDA)
    self.pca = PCA9685(i2c_bus)
    self.pca.frequency = 50
    self.bottomServo = servo.Servo(self.pca.channels[config.SERVO_BELOW_CHANNEL], min_pulse=500, max_pulse=2500)
    self.topServo = servo.Servo(self.pca.channels[config.SERVO_TOP_CHANNEL], min_pulse=500, max_pulse=2500)

Relay

I used a JQC3F 5V DC optocoupler relay. You could wire the whole thing with fuses, resistors, and a MOSFET, but I didn’t get great results. Also, if I’m being honest, I wanted something that was harder for me to accidentally wire into a small fire. A relay is safer and cheaper when you’re playing with 12V. Less chance of magic smoke. I already blew up a Raspberry Pi (don’t tell my wife). I’m learning. Slowly.

The pump is controlled through a GPIO pin with a cooldown timer so we don’t overheat anything:

Python
UTF-8|5 Lines|
def trigger(self):
    current_time = time.time()
    if (current_time - self.last_action_time) >= config.COOLDOWN_TIME:
        self.__fire()
        self.last_action_time = current_time

Gun

Wait, no. This is a water gun that squirts water. Please calm down.

The relay triggers a small agricultural pump with 1 gallon/min water flow. The water is pumped through a pressure tip at the end of the hose. Because it’s a low pressure pump, it’s very gentle. The fire logic waits for a few confirmation frames before pulling the trigger.

Python
UTF-8|7 Lines|
distance_from_center = math.sqrt(error_pan**2 + error_tilt**2)

if distance_from_center < config.SPRAY_THRESHOLD_PIXELS:
    state.strike_count += 1
    if state.strike_count >= config.STRIKE_CONFIRMATION_FRAMES:
        self.pump_control.trigger()
        state.strike_count = 0

Why?

I’ll preface this by saying I love all animals and birds. But I have a small yet intense hatred for pigeons. More on that later.

Last autumn, there was a pigeon couple doing lovey-dovey stuff (pun intended). I tried to shoo them away. They retorted by building a nest.

After many unsuccessful attempts to convince them to leave, I decided I needed a more robust solution to discourage them. But mostly because I don’t want my wife to think I lost a fight with pigeons.

Engineering Trade-Offs

None of these are universal truths - they’re just the choices that made my balcony setup behave. If yours is different (lighting, distance, lens, bird boldness), your knobs will be different too.

1. Resolution vs. Field of View (FOV)

Tradeoff: Dropping resolution to save CPU vs. keeping resolution to see detail.

Why:

We moved from 640×480 down to 320×240. Because the Pi 5 can use pixel binning on the Camera Module 3, we reduced the pixel data by 4x (drastically lowering latency) without losing any of the wide angle view needed to catch those darn creatures ruining my balcony.

Python
UTF-8|2 Lines|
FRAME_WIDTH = 320
FRAME_HEIGHT = 240

2. Optical Focus: Auto vs. Fixed

Tradeoff: Disabled Autofocus (AF) and locked the LensPosition to a fixed value (approx. 1.5).

Why:

AF was creating “hunting” lag and variable frame rates. By locking focus for the 1-3 meter balcony range, we eliminated “buffering” and ensured the AI always sees sharp edges, keeping confidence scores stable. It took some time to tune the confidence scores because the weather was causing issues with detection. More on that in the next step.

Python
UTF-8|3 Lines|
AfMode = 0           # manual focus
LensPosition = 1.5   # locked for ~1-3m range
CONFIDENCE_THRESHOLD = 0.5  # 0.85 on the actual balcony

3. Image Data: Color vs. Grayscale

Tradeoff: Feature richness vs. processing speed.

Why:

In a balcony environment (pigeons on concrete), grayscale removes the color contrast that helps the AI distinguish a gray bird from a gray floor. Gray on gray, it’s like camouflage the freaking pigeons didn’t even have to try for. Since the Pi 5 has plenty of processing power, I opted for accuracy over speed.

Python
UTF-8|1 Line|
frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR)  # keep the colors

4. Mathematical Precision: Integer vs. Float

Tradeoff: Code simplicity vs. movement smoothness.

Why:

I credit Gemini (the free one, I’m too poor for the pro version) with the idea.

Switched to sub-pixel (floating point) math for both the UI and the servo logic.

At 320×240, a 1 pixel jump is visually significant. Using floats and the OpenCV shift parameter allows the camera to glide smoothly and the servos to react to micro movements (0.1 pixels) rather than waiting for the bird to cross a whole pixel boundary. The shift trick scales coordinates up by 2³ so OpenCV can use fractional positions:

Python
UTF-8|4 Lines|
shift = 3
factor = 1 << shift  # multiply coords by 8 for sub-pixel precision
p1_s = (int(round(p1[0] * factor)), int(round(p1[1] * factor)))
cv2.circle(img, p1_s, radius_s, color, thickness, lineType=cv2.LINE_AA, shift=shift)

5. Reactive Tracking vs. Predictive Tracking

Tradeoff: Leading the target vs. system stability. Although predictive tracking would be more hi fi and modern, it added jitter and didn’t have the smoothness I was looking for. I prioritized jitter reduction over speed, so I chose the sigmoid based system.

Why:

A look-ahead prediction system is pretty cool and an interesting problem.

But my requirement is for a balcony where birds hop and stop but rarely linger. Just like happiness. Maybe I should be a poet.

Anyway, prediction adds “overshoot” risk and jitter. A high frequency reactive loop (running at 30+ FPS) is more than fast enough to track a pigeon without the camera swinging wildly.

The sigmoid function gives us that smooth S-curve response instead of violent linear corrections. The deadband prevents micro jitters, and the sigmoid keeps things buttery:

Python
UTF-8|4 Lines|
def sigmoidAdjustment(error, adjustmentFactor):
    sensitivity = 50
    val = 1.0 / (1.0 + math.exp(-error / sensitivity))
    return adjustmentFactor * (val - 0.5) * 2

6. Control Loop: Synchronous vs. Threaded

Tradeoff: Maximum throughput vs. mechanical reliability.

Why: I was curious about squeezing more performance out of this system. I looked into multithreading and found that doing that to servos on a Pi can lead to “command stacking” and late movements. Since the 320×240 resolution made the main loop fast enough, we didn’t need the risk of threads to achieve real-time performance. Sometimes the boring solution is the right one.

The whole thing runs in a single tight loop. Dependencies are injected at startup and everything flows through one thread:

Python
UTF-8|6 Lines|
tracker_system = PigeonTracker(model, targeting_system, state)

while True:
    success = tracker_system.startDetection(picam)
    if not success or (cv2.waitKey(1) & 0xFF == ord('q')):
        break

When the target is lost for too long (~3 seconds), the system gives up and goes home:

Python
UTF-8|2 Lines|
if self.state.lost_object_count >= config.MAX_LOST_FRAMES:
    self.serv.reset_to_baseline(self.state)

That’s it. Imma go now too.