Eric Bergman-Terrell's Blog

Raspberry Pi Camera Trap and Animal Detection Program
August 26, 2026

Camera Traps

A "camera trap" is just a camera that automatically captures images of animals. While camera traps are a wonderful, non-invasive way to photograph wildlife, they tend to capture hundreds or thousands of images that don't contain any animals. Often the effects of wind will be misconstrued as animals moving in front of the lens.

Manually filtering through so many images can be a total waste of time!

MegaDetector for the Win!

Microsoft's MegaDetector is an AI model that identifies animals, people, and vehicles in camera trap images. It's perfect for reviewing camera trap images and finding the ones that are probably images of animals.


collared lizard found by MegaDetector

Eastern Collared Lizard (Crotaphytus collaris) a.k.a. "mountain boomer" captured by my camera trap, and found by MegaDetector

My camera trap is just a Raspberry Pi 3B+ with an inexpensive camera, powered by an Anker power pack. It's disguised as a mis-delivered package. The app just snaps a picture every minute. Because the camera is mounted upside-down, it automatically corrects the images with this transform:

transform=Transform(hflip=True, vflip=True)

I previously tried triggering the camera with motion detection hardware. I used an "HC-SR501 PIR Infrared Sensor". This device resulted in way too many false positives and false negatives. It had a problem with direct sunlight and electrical interference. Also, because it's triggered by body heat, it didn't detect the beautiful reptiles that prowl around Southwestern Colorado.

Some Raspberry Pi-based camera traps do the image analysis and classification in the device itself. While that is frankly amazing, I prefer to conserve the camera trap's battery. After my camera trap has amassed a day's worth of photos, I download them to a flash drive, and use my PC to find the animal photos with MegaDetector.

Note: you'll need to change the latitude and longitude values in the camera trap program to match your location. The arguments to the dms_to_decimal function are the degrees, minutes, and seconds of latitude or longitude:

latitude  =  dms_to_decimal(37, 58, 17) # Positive N of equator,   Negative S
longitude = -dms_to_decimal(108, 5, 14) # Positive E of Greenwich, Negative W

Camera Trap Program for Raspberry Pi 3B+:

import sys
import os
import logging
import logging.config
from datetime import datetime, time
from pathlib import Path
import datetime
from pysunnoaa import noaa
from picamera2 import Picamera2
from libcamera import Transform


def dms_to_decimal(degrees, minutes, seconds):
    return degrees + minutes / 60.0 + seconds / 3600.0


latitude  =  dms_to_decimal( 37, 00, 00)  # Positive N of equator,   Negative S
longitude = -dms_to_decimal(108, 00, 00)  # Positive E of Greenwich, Negative W

logger = None

photo_quality = 90

photo_width = 1920
photo_height = 1080

# Amount of time before sunrise and after sunset to continue taking pictures.
daylight_margin = datetime.timedelta(minutes=30)

photos_folder = "/home/eric/Documents/image_capture/photos"

# Take a photo every photo_interval seconds
photo_interval = 60 * 1


def setup_logging():
    global logger

    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
        handlers=[
            logging.FileHandler('log.log'),  # Write logs to a file
            logging.StreamHandler()  # Print logs to the console
        ]
    )

    logger = logging.getLogger(__name__)


def is_daytime():
    def get_utc_offset_hours():
        local_now = datetime.datetime.now().astimezone()
        offset_timedelta = local_now.utcoffset()
        utc_offset_hours = offset_timedelta.total_seconds() / 3600

        return utc_offset_hours

    now = datetime.datetime.now()

    utc_offset_hours = get_utc_offset_hours()

    sunrise = noaa.sunrise(latitude, longitude, utc_offset_hours, now)
    sunset = noaa.sunset(latitude, longitude, utc_offset_hours, now)

    begin = sunrise - daylight_margin
    end = sunset + daylight_margin

    result = begin <= now <= end

    logger.info(f'is_daytime: sunrise: {sunrise}, sunset: {sunset} now: {now}')
    logger.info(f'daylight_margin: {daylight_margin} begin: {begin} end: {end} result: {result}')

    return result


def take_photo(photos_path):
    def get_photo_filename():
        now = datetime.datetime.now()
        return f'{now.year:04}{now.month:02}{now.day:02}_{now.hour:02}{now.minute:02}{now.second:02}.jpg'

    photo_file_path = f'{photos_path}{os.path.sep}{get_photo_filename()}'

    logger.info(f'taking photograph "{photo_file_path}"')

    with Picamera2() as cam:
        try:
            # The transform compensates for the camera being upside-down.
            config = cam.create_still_configuration({"size": (photo_width, photo_height)},
                                                    transform=Transform(hflip=True, vflip=True))

            cam.configure(config)
            cam.options['quality'] = photo_quality

            cam.start()
            sys.modules['time'].sleep(2)  # Give the sensor time to adjust to light levels

            cam.capture_file(photo_file_path)

            cam.stop()
            logger.info(f'Photo captured. size: {os.stat(photo_file_path).st_size:,} bytes')
        except Exception as ex:
            logger.info(f'Exception: {ex}')


def main():
    global logger

    setup_logging()

    logger.info(f'photo_interval: {photo_interval}')

    photos_path = Path(photos_folder)

    logger.info(f'photos_path: "{photos_path}"')

    if not photos_path.is_dir():
        photos_path.mkdir()

    while True:
        try:
            if is_daytime():
                take_photo(photos_path)

            sys.modules['time'].sleep(photo_interval)
        except Exception as ex:
            logger.exception('Exception', ex)


if __name__ == '__main__':
    main()

You will want to run the above script from a shell that won't be shut down. I recommend "tmux" for this purpose:

With tmux, run the script as follows (assuming it's named "main.py"):

tmux new
sudo python main.py

Here's the script I use to go through a set of photos to find the interesting ones:

Note: MegaDetector does not currently run on the latest version of Python. I use Python 3.12.

Note: I only use MegaDetector to determine if there may be animals in a photo. I do not use it to further classify the animals. I have not found the classification to be very accurate, at least for the denizens of my back yard.


Photo Filtering Program for PC:

import os
import shutil
import time
from pathlib import Path
from PytorchWildlife.models import detection as pw_detection
import logging
import logging.config
from PIL import Image, ImageDraw
import pickle
import numpy as np
import numpy.typing as npt
from typing import Any

logger: logging.Logger

# https://github.com/microsoft/MegaDetector

# Requires python version <= 3.12.

# Models
#
# MDV6-yolov9-c, MDV6-yolov9-e, MDV6-yolov10-c, MDV6-yolov10-e, MDV6-rtdetr-c

model_version: str = 'MDV6-yolov10-e'

detection_threshold: float = 0.70

highlight_rectangle_width: int = 4
highlight_rectangle_color: str = 'red'

items_to_find = {'animal', 'person', 'vehicle'}

# Approximation factor for spurious rectangle detection. Increasing value makes filtering more aggressive.
approximation_factor: int = 20


def setup_logging() -> None:
    global logger

    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
        handlers=[
            logging.FileHandler('log.log'),  # Write logs to a file
            logging.StreamHandler()  # Print logs to the console
        ]
    )

    logger = logging.getLogger(__name__)


def classify_images(photos_path, output_path) -> None:
    def item_detected(labels) -> bool:
        def is_item(label) -> bool:
            values = label.split(' ')

            return (values[0] in items_to_find) and float(values[1]) >= detection_threshold

        return any(is_item(label) for label in labels)

    def highlight_items(src_image_file_path, dest_image_file_path, detection_result) -> None:
        logger.info(
            f'highlight_items: src_image_file_path: "{src_image_file_path}" dest_image_file_path: "{dest_image_file_path}" detection_result: {detection_result}')

        img = Image.open(src_image_file_path)
        draw = ImageDraw.Draw(img)

        for detection in detection_result["detections"]:
            rect = detection[0]
            draw.rectangle((max(0, rect[0] - highlight_rectangle_width), max(0, rect[1] - highlight_rectangle_width), min(img.width, rect[2] + highlight_rectangle_width), min(img.height, rect[3] + highlight_rectangle_width)), outline=highlight_rectangle_color, width=highlight_rectangle_width)

        img.save(dest_image_file_path)

    start_time = time.perf_counter()

    model = pw_detection.MegaDetectorV6(None, 'cpu', True, model_version)

    photos_processed = 0

    for photo_path in [f for f in photos_path.rglob("*") if f.is_file()]:
        detection_result = model.single_image_detection(os.path.abspath(photo_path))

        if item_detected(detection_result["labels"]):
            logger.info(f'ANIMAL: "{photo_path}": {detection_result}\n"{photo_path}"')

            dest_image_file_path = Path(output_path, os.path.basename(photo_path))

            text_file_path = Path(output_path, os.path.basename(photo_path)).with_suffix('.txt')
            with open(text_file_path, "w", encoding="utf-8") as text_file:
                text_file.write(f'{detection_result}')

            binary_file_path = Path(output_path, os.path.basename(photo_path)).with_suffix('.bin')
            with open(binary_file_path, "wb") as binary_file:
                pickle.dump(detection_result, binary_file)

            highlight_items(photo_path, dest_image_file_path, detection_result)

            photos_processed += 1

    logger.info(f'Processed {photos_processed} Elapsed Time: {(time.perf_counter() - start_time):,.2f} s')


def approximate_array(array: npt.NDArray[np.float64]) -> npt.NDArray[np.int_]:
    approx_list = []

    for index, value in np.ndenumerate(array):
        approx_list.append(int(value // approximation_factor))

    return np.array(approx_list)


def process_rectangles(output_path) -> dict[str, Any]:
    rectangles: dict[str, Any] = {}

    for info_file in [f for f in output_path.rglob("*.bin") if f.is_file()]:
        print(f'INFO: {info_file}')

        # Save every rectangle that exceeded the threshold in memory, but save an approximated value so that similar
        # rectangles are considered identical.
        with open(info_file, "rb") as input_file:
            content = input_file.read()

            data = pickle.loads(content)
            print(f'data: {data}')

            for index, value in enumerate(data['detections']):
                print(f'index: {index}, value: {value}')

                # Why are these arrays, when the detections are already in an array?
                if len(data['detections'][index].confidence) != 1 or len(data['detections'][index].xyxy) != 1:
                    raise 'unexpected array length'

                confidence = data['detections'][index].confidence
                xyxy = approximate_array(data['detections'][index].xyxy)

                if confidence[0] >= detection_threshold:
                    key = str(xyxy)

                    if not(key in rectangles):
                        rectangles[key] = { 'data': xyxy, 'count': 1 }
                    else:
                        rectangles[key]['count'] += 1

    return rectangles


def delete_spurious_images(output_path, spurious_path, rectangles: dict[str, Any]) -> None:
    spurious_files: list[Path] = []

    for info_file in [f for f in output_path.rglob("*.bin") if f.is_file()]:
        print(f'INFO: {info_file}')

        # Save every rectangle that exceeded the threshold in memory, but round the coordinates to 3 significant digits.
        with open(info_file, "rb") as input_file:
            content = input_file.read()

            data = pickle.loads(content)
            print(f'data: {data}')

            max_confidence: float = -1.0

            for index, value in enumerate(data['detections']):
                print(f'index: {index}, value: {value}')

                # Why are these arrays, when the detections are already in an array?
                if len(data['detections'][index].confidence) != 1 or len(data['detections'][index].xyxy) != 1:
                    raise 'unexpected array length'

                confidence = data['detections'][index].confidence
                xyxy = approximate_array(data['detections'][index].xyxy)

                # Ignore the photo if the rectangle is in multiple photos.
                if confidence >= detection_threshold and rectangles[str(xyxy)]['count'] == 1:
                    max_confidence = max(max_confidence, confidence)

            if max_confidence < detection_threshold:
                print(f'Delete {info_file}')

                wildcard = f'{Path(info_file).stem}.*'
                for file_path in output_path.glob(wildcard):
                    spurious_files.append(file_path)

    for file_path in spurious_files:
        shutil.move(file_path, spurious_path)


def invert_images(folder_path) -> None:
    print('invert_images')

    for file_path in [f for f in folder_path.rglob("*") if f.is_file()]:
        print(f'{file_path}')

        img = Image.open(file_path)

        flipped = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
        flipped.save(file_path)


def main():
    setup_logging()

    logger.info(f'detection_threshold: {detection_threshold:.2f}\n')

    root_folder = Path('I:\\temp\\animals\\2026-08-28\\Winnie')
    photos_path = Path(root_folder, 'photos')
    output_path = Path(root_folder, 'output')
    spurious_path = Path(root_folder, 'spurious')

    logger.info(f'photos_path:   "{photos_path}"')
    logger.info(f'output_path:   "{output_path}"')
    logger.info(f'spurious_path: "{spurious_path}"')

    try:
        shutil.rmtree(output_path, True)
        output_path.mkdir()

        shutil.rmtree(spurious_path, True)
        spurious_path.mkdir()

        classify_images(photos_path, output_path)

        rectangles = process_rectangles(output_path)
        delete_spurious_images(output_path, spurious_path, rectangles)
    except Exception as ex:
        logger.exception(f'Fatal Error', ex)


if __name__ == '__main__':
    main()


camera trap implemented on Raspberry Pi 3B+

Raspberry Pi Camera Trap

Keywords: Camera Trap, Raspberry Pi, MegaDetector, AI, Image Recognition, Image Classification, Python, HC-SR501 PIR Infrared Sensor

Reader Comments

Comment on this Blog Post

Recent Posts

TitleDate
Raspberry Pi Camera Trap and Animal Detection ProgramAugust 26, 2026
Python Tip: Fix Incorrect Orientation of Digital PhotosAugust 8, 2026
EBT Weather is now available for Windows and LinuxMay 30, 2026
Node.js + Express: How to Block Requests by User-Agent HeadersJanuary 7, 2026
Vault 3 is Now Available for Windows on ARM Machines!December 13, 2025
Vault 3: How to Include Outline Text in Exported PhotosOctober 26, 2025
.NET Public-Key (Asymmetric) Cryptography DemoJuly 20, 2025