Operation Epic Furry: A Machine Learning based Raspberry Pi-Powered Pigeon Defense System
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:
picam = detect.camera_init()
model = detect.model_init() # model path is defined in config.pyThe 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.
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 trackerArms
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:
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:
def trigger(self):
current_time = time.time()
if (current_time - self.last_action_time) >= config.COOLDOWN_TIME:
self.__fire()
self.last_action_time = current_timeGun
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.
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 = 0Why?
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.
FRAME_WIDTH = 320
FRAME_HEIGHT = 2402. 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.
AfMode = 0 # manual focus
LensPosition = 1.5 # locked for ~1-3m range
CONFIDENCE_THRESHOLD = 0.5 # 0.85 on the actual balcony3. 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.
frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR) # keep the colors4. 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:
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:
def sigmoidAdjustment(error, adjustmentFactor):
sensitivity = 50
val = 1.0 / (1.0 + math.exp(-error / sensitivity))
return adjustmentFactor * (val - 0.5) * 26. 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:
tracker_system = PigeonTracker(model, targeting_system, state)
while True:
success = tracker_system.startDetection(picam)
if not success or (cv2.waitKey(1) & 0xFF == ord('q')):
breakWhen the target is lost for too long (~3 seconds), the system gives up and goes home:
if self.state.lost_object_count >= config.MAX_LOST_FRAMES:
self.serv.reset_to_baseline(self.state)That’s it. Imma go now too.