|
| 1 | +# SPDX-FileCopyrightText: 2025 Liz Clark for Adafruit Industries |
| 2 | +# |
| 3 | +# SPDX-License-Identifier: MIT |
| 4 | +'''Raspberry Pi Halloween Costume Detector with OpenCV and Claude Vision API''' |
| 5 | + |
| 6 | +#!/usr/bin/python3 |
| 7 | +import os |
| 8 | +import subprocess |
| 9 | +import time |
| 10 | +import base64 |
| 11 | +import wave |
| 12 | +import cv2 |
| 13 | +from picamera2 import Picamera2 |
| 14 | +import anthropic |
| 15 | +from piper import PiperVoice, SynthesisConfig |
| 16 | +import board |
| 17 | +import digitalio |
| 18 | + |
| 19 | +ANTHROPIC_API_KEY = "your-api-key-here" |
| 20 | + |
| 21 | +try: |
| 22 | + username = os.getlogin() |
| 23 | + print(f"The current user is: {username}") |
| 24 | +except OSError: |
| 25 | + print("Could not determine the login name.") |
| 26 | + print("Consider checking environment variables like USER or LOGNAME.") |
| 27 | + # Fallback to environment variables if os.getlogin() fails |
| 28 | + username = os.environ.get('USER') or os.environ.get('LOGNAME') |
| 29 | + if username: |
| 30 | + print(f"The user from environment variable is: {username}") |
| 31 | + else: |
| 32 | + print("User information not found in environment variables either.") |
| 33 | + |
| 34 | +# Initialize LED |
| 35 | +led = digitalio.DigitalInOut(board.D5) |
| 36 | +led.direction = digitalio.Direction.OUTPUT |
| 37 | +led.value = False # Start with LED off |
| 38 | + |
| 39 | +# Initialize detectors |
| 40 | +upperbody_detector = cv2.CascadeClassifier( |
| 41 | + "/usr/share/opencv4/haarcascades/haarcascade_upperbody.xml") |
| 42 | +fgbg = cv2.createBackgroundSubtractorMOG2(detectShadows=False) |
| 43 | + |
| 44 | +# Initialize camera |
| 45 | +cv2.startWindowThread() |
| 46 | +picam2 = Picamera2() |
| 47 | +picam2.configure(picam2.create_preview_configuration( |
| 48 | + main={"format": 'XRGB8888', "size": (640, 480)})) |
| 49 | +picam2.start() |
| 50 | + |
| 51 | +# Initialize Piper voice |
| 52 | +voice = PiperVoice.load("/home/pi/en_US-joe-medium.onnx") |
| 53 | +syn_config = SynthesisConfig( |
| 54 | + volume=1.0, |
| 55 | + length_scale=1.5, |
| 56 | + noise_scale=1.2, |
| 57 | + noise_w_scale=1.5, |
| 58 | + normalize_audio=False, |
| 59 | +) |
| 60 | + |
| 61 | +# Check for and create audio files if they don't exist |
| 62 | +def create_audio_file_if_needed(filename, text): |
| 63 | + """Create audio file if it doesn't exist""" |
| 64 | + if not os.path.exists(filename): |
| 65 | + print(f"Creating {filename}...") |
| 66 | + with wave.open(filename, "wb") as wav: |
| 67 | + voice.synthesize_wav(text, wav, syn_config=syn_config) |
| 68 | + print(f"{filename} created!") |
| 69 | + else: |
| 70 | + print(f"{filename} already exists.") |
| 71 | + |
| 72 | +# Create the hello and halloween audio files |
| 73 | +create_audio_file_if_needed("hello.wav", "Hello? Who goes there?") |
| 74 | +create_audio_file_if_needed("halloween.wav", "Happy Halloween!") |
| 75 | + |
| 76 | +# Initialize Anthropic client |
| 77 | +client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY) |
| 78 | +# set API key via the terminal as an environmental variable: |
| 79 | +# export ANTHROPIC_API_KEY="your-api-key-here" |
| 80 | + |
| 81 | +# Detection parameters |
| 82 | +MOTION_THRESHOLD = 100000 |
| 83 | +COOLDOWN_SECONDS = 15 |
| 84 | +last_capture_time = 0 |
| 85 | +capture_delay = 5 |
| 86 | +motion_frame_count = 0 |
| 87 | +MOTION_FRAMES_REQUIRED = 5 |
| 88 | + |
| 89 | +def encode_image(image_path): |
| 90 | + """Encode image to base64 for Claude API""" |
| 91 | + with open(image_path, "rb") as image_file: |
| 92 | + return base64.standard_b64encode(image_file.read()).decode("utf-8") |
| 93 | + |
| 94 | +def get_costume_joke(image_path): |
| 95 | + """Send image to Claude and get dad joke about costume""" |
| 96 | + print("Analyzing costume with Claude...") |
| 97 | + |
| 98 | + # Encode the image |
| 99 | + image_data = encode_image(image_path) |
| 100 | + |
| 101 | + # Send to Claude API |
| 102 | + message = client.messages.create( |
| 103 | + model="claude-sonnet-4-5-20250929", |
| 104 | + max_tokens=250, |
| 105 | + messages=[ |
| 106 | + { |
| 107 | + "role": "user", |
| 108 | + "content": [ |
| 109 | + { |
| 110 | + "type": "image", |
| 111 | + "source": { |
| 112 | + "type": "base64", |
| 113 | + "media_type": "image/jpeg", |
| 114 | + "data": image_data, |
| 115 | + }, |
| 116 | + }, |
| 117 | + { |
| 118 | + "type": "text", |
| 119 | + "text": """Look at this image. |
| 120 | + Your response must follow these rules exactly: |
| 121 | +
|
| 122 | + 1. If you see a person in a Halloween costume, respond with ONLY a single-sentence cute, |
| 123 | + family-friendly, encouraging, dad joke about the costume. Nothing else. |
| 124 | +
|
| 125 | + 2. If you do NOT see a clear Halloween costume (empty room, unclear image, person in regular clothes, etc.), |
| 126 | + respond with ONLY the character: 0 |
| 127 | +
|
| 128 | + Examples of good responses: |
| 129 | + - "Looks like that ghost costume is really boo-tiful!" |
| 130 | + - "0" |
| 131 | + - "I guess you could say that vampire costume really sucks... in a good way!" |
| 132 | +
|
| 133 | + Do not add any explanations, commentary, or descriptions. Just the joke or just 0. |
| 134 | + """ |
| 135 | + } |
| 136 | + ], |
| 137 | + } |
| 138 | + ], |
| 139 | + ) |
| 140 | + |
| 141 | + # Extract the joke from the response |
| 142 | + joke = message.content[0].text |
| 143 | + print(f"Claude's joke: {joke}") |
| 144 | + return joke |
| 145 | +# pylint: disable=subprocess-run-check, broad-except |
| 146 | +def play_audio_file(filename): |
| 147 | + """Play a pre-existing audio file""" |
| 148 | + print(f"Playing {filename}...") |
| 149 | + subprocess.run(["su", username, "-c", f"aplay {filename}"]) |
| 150 | + |
| 151 | +def speak_joke(joke_text): |
| 152 | + """Convert joke to speech and play it""" |
| 153 | + print("Generating speech...") |
| 154 | + |
| 155 | + wav_file = "joke.wav" |
| 156 | + |
| 157 | + # Generate audio with Piper |
| 158 | + with wave.open(wav_file, "wb") as wav: |
| 159 | + voice.synthesize_wav(joke_text, wav, syn_config=syn_config) |
| 160 | + |
| 161 | + print("Playing audio...") |
| 162 | + # Play the audio file (using aplay for Raspberry Pi) |
| 163 | + subprocess.run(["su", username, "-c", f"aplay {wav_file}"]) |
| 164 | + |
| 165 | + # Optional: clean up the audio file after playing |
| 166 | + # os.remove(wav_file) |
| 167 | + |
| 168 | +# Main loop |
| 169 | +while True: |
| 170 | + im = picam2.capture_array() |
| 171 | + grey = cv2.cvtColor(im, cv2.COLOR_BGR2GRAY) |
| 172 | + |
| 173 | + # Check for motion |
| 174 | + fgmask = fgbg.apply(im) |
| 175 | + motion_amount = cv2.countNonZero(fgmask) |
| 176 | + motion_detected = motion_amount > MOTION_THRESHOLD |
| 177 | + |
| 178 | + if motion_detected: |
| 179 | + motion_frame_count += 1 |
| 180 | + else: |
| 181 | + motion_frame_count = 0 |
| 182 | + |
| 183 | + if motion_frame_count >= MOTION_FRAMES_REQUIRED: |
| 184 | + # Detect upperbody |
| 185 | + bodies = upperbody_detector.detectMultiScale(grey, 1.1, 3) |
| 186 | + person_detected = len(bodies) > 0 |
| 187 | + |
| 188 | + # Draw rectangles |
| 189 | + for (x, y, w, h) in bodies: |
| 190 | + cv2.rectangle(im, (x, y), (x + w, y + h), (0, 255, 0), 2) |
| 191 | + |
| 192 | + # Process if person detected and cooldown passed |
| 193 | + current_time = time.time() |
| 194 | + if person_detected and (current_time - last_capture_time) > COOLDOWN_SECONDS: |
| 195 | + print("Person detected!") |
| 196 | + |
| 197 | + # Turn on LED and play hello message |
| 198 | + led.value = True |
| 199 | + play_audio_file("hello.wav") |
| 200 | + |
| 201 | + im = picam2.capture_array() |
| 202 | + # Capture image |
| 203 | + timestamp = time.strftime("%Y%m%d-%H%M%S") |
| 204 | + file = f"costume_{timestamp}.jpg" |
| 205 | + cv2.imwrite(file, im) |
| 206 | + print(f"\nPicture saved: {file}") |
| 207 | + |
| 208 | + try: |
| 209 | + # Get joke from Claude |
| 210 | + the_joke = get_costume_joke(file) |
| 211 | + |
| 212 | + # If Claude returns 0, use the halloween fallback |
| 213 | + if the_joke.strip() == "0": |
| 214 | + print("No costume detected, playing halloween.wav") |
| 215 | + play_audio_file("halloween.wav") |
| 216 | + else: |
| 217 | + # Speak the joke |
| 218 | + speak_joke(the_joke) |
| 219 | + |
| 220 | + except Exception as e: |
| 221 | + print(f"Error: {e}") |
| 222 | + # Fallback to halloween.wav if something goes wrong |
| 223 | + play_audio_file("halloween.wav") |
| 224 | + |
| 225 | + # Turn off LED after processing |
| 226 | + led.value = False |
| 227 | + |
| 228 | + last_capture_time = current_time |
| 229 | + motion_frame_count = 0 |
| 230 | + |
| 231 | + # Show motion amount |
| 232 | + cv2.putText(im, f"Motion: {motion_amount}", (10, 30), |
| 233 | + cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2) |
| 234 | + |
| 235 | + cv2.imshow("Camera", im) |
| 236 | + cv2.waitKey(1) |
0 commit comments