adding raspberry pi thermal camera code
Adding raspberry pi thermal camera overlay code. uses pi camera 3 and mlx90640
This commit is contained in:
parent
235f0c4938
commit
31426aeddc
1 changed files with 316 additions and 0 deletions
316
Raspberry_Pi_Thermal_Camera_Overlay/code.py
Normal file
316
Raspberry_Pi_Thermal_Camera_Overlay/code.py
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
# SPDX-FileCopyrightText: 2025 Liz Clark for Adafruit Industries
|
||||
#
|
||||
# SPDX-License-Identifier: MIT
|
||||
"""
|
||||
Thermal Camera Overlay for Raspberry Pi 4,
|
||||
PiCamera 3 and STEMMA MLX90640
|
||||
|
||||
Inspired by PitFusion Thermal Imager
|
||||
"""
|
||||
|
||||
import time
|
||||
import numpy as np
|
||||
import cv2
|
||||
import board
|
||||
import busio
|
||||
import adafruit_mlx90640
|
||||
from picamera2 import Picamera2
|
||||
from PIL import Image
|
||||
|
||||
# Temperature range for thermal camera (in Celsius)
|
||||
MIN_TEMP = 20.0
|
||||
MAX_TEMP = 35.0
|
||||
|
||||
# Thermal overlay opacity (0.0 = invisible, 1.0 = fully opaque)
|
||||
THERMAL_OPACITY = 0.7
|
||||
|
||||
# Display window size
|
||||
WINDOW_WIDTH = 1280
|
||||
WINDOW_HEIGHT = 720
|
||||
|
||||
# Camera settings
|
||||
CAMERA_WIDTH = 1280
|
||||
CAMERA_HEIGHT = 720
|
||||
|
||||
SKIP_FRAMES = 2 # Process every Nth frame for thermal
|
||||
frame_counter = 0
|
||||
|
||||
# Thermal camera size
|
||||
THERMAL_WIDTH = 32
|
||||
THERMAL_HEIGHT = 24
|
||||
|
||||
# Thermal zoom factor (1.7x to compensate for FoV difference)
|
||||
# Thermal camera FoV: 110°x75°, Pi camera FoV: 66°x41°
|
||||
# Ratio: 66/110 = 0.6, so we need 1/0.6 = 1.67x zoom
|
||||
THERMAL_ZOOM = 1.7
|
||||
|
||||
# Camera crop settings to compensate for thermal offset
|
||||
# This crops the camera image to match the thermal coverage area
|
||||
CAMERA_CROP_LEFT = 65 # Match thermal X offset
|
||||
CAMERA_CROP_TOP = 85 # Match thermal Y offset
|
||||
CAMERA_CROP_RIGHT = 0 # No crop on right
|
||||
CAMERA_CROP_BOTTOM = 0 # No crop on bottom
|
||||
|
||||
# Calculate effective camera size after cropping
|
||||
CAMERA_CROP_WIDTH = CAMERA_WIDTH - CAMERA_CROP_LEFT - CAMERA_CROP_RIGHT
|
||||
CAMERA_CROP_HEIGHT = CAMERA_HEIGHT - CAMERA_CROP_TOP - CAMERA_CROP_BOTTOM
|
||||
|
||||
# ============= SETUP THERMAL CAMERA =============
|
||||
print("Setting up thermal camera...")
|
||||
i2c = busio.I2C(board.SCL, board.SDA)
|
||||
mlx = adafruit_mlx90640.MLX90640(i2c)
|
||||
mlx.refresh_rate = adafruit_mlx90640.RefreshRate.REFRESH_4_HZ
|
||||
|
||||
# Create array to hold thermal data
|
||||
thermal_frame = np.zeros(768, dtype=np.float32)
|
||||
|
||||
# ============= SETUP REGULAR CAMERA =============
|
||||
print("Setting up Pi camera...")
|
||||
picam2 = Picamera2()
|
||||
camera_config = picam2.create_preview_configuration(
|
||||
main={"size": (CAMERA_WIDTH, CAMERA_HEIGHT), "format": "RGB888"},
|
||||
buffer_count=2, # Reduce buffer count for lower latency
|
||||
queue=False # Don't queue frames
|
||||
)
|
||||
picam2.configure(camera_config)
|
||||
picam2.start()
|
||||
picam2.set_controls({"ExposureTime": 20000, "AnalogueGain": 1.0})
|
||||
time.sleep(2)
|
||||
|
||||
# ============= CREATE THERMAL COLORMAP =============
|
||||
def create_thermal_colormap():
|
||||
"""Create a colormap for thermal visualization"""
|
||||
# Define color points (blue -> cyan -> green -> yellow -> orange -> red)
|
||||
colors = np.array([
|
||||
[0, 0, 64], # Dark blue (cold)
|
||||
[0, 0, 255], # Blue
|
||||
[0, 255, 255], # Cyan
|
||||
[0, 255, 0], # Green
|
||||
[255, 255, 0], # Yellow
|
||||
[255, 128, 0], # Orange
|
||||
[255, 0, 0], # Red (hot)
|
||||
], dtype=np.uint8)
|
||||
|
||||
# Create smooth gradient between colors
|
||||
colormap = np.zeros((256, 3), dtype=np.uint8)
|
||||
positions = np.linspace(0, len(colors)-1, 256)
|
||||
|
||||
for i in range(256):
|
||||
pos = positions[i]
|
||||
idx = int(pos)
|
||||
frac = pos - idx
|
||||
|
||||
if idx >= len(colors) - 1:
|
||||
colormap[i] = colors[-1]
|
||||
else:
|
||||
colormap[i] = (1 - frac) * colors[idx] + frac * colors[idx + 1]
|
||||
|
||||
colormap = colormap[::-1] # Reverse the colormap
|
||||
return colormap
|
||||
the_colormap = create_thermal_colormap()
|
||||
|
||||
# ============= HELPER FUNCTIONS =============
|
||||
def process_thermal_frame(thermal_data, colormap):
|
||||
"""Convert thermal data to colored image"""
|
||||
# Calculate temperature statistics
|
||||
min_temp = np.min(thermal_data)
|
||||
max_temp = np.max(thermal_data)
|
||||
avg_temp = np.mean(thermal_data)
|
||||
if min_temp < -100:
|
||||
min_temp = MIN_TEMP
|
||||
avg_temp = (MIN_TEMP + MAX_TEMP) / 2
|
||||
|
||||
# Normalize temperature data to 0-255 range
|
||||
normalized = np.clip(
|
||||
(thermal_data - MIN_TEMP) / (MAX_TEMP - MIN_TEMP) * 255,
|
||||
0, 255
|
||||
).astype(np.uint8)
|
||||
|
||||
# Apply colormap
|
||||
colored = colormap[normalized]
|
||||
|
||||
# Reshape to 2D image (24x32x3)
|
||||
thermal_image = colored.reshape(THERMAL_HEIGHT, THERMAL_WIDTH, 3)
|
||||
|
||||
# Flip horizontally to match camera view
|
||||
thermal_image = np.fliplr(thermal_image)
|
||||
|
||||
# Scale up to camera size using PIL for smooth interpolation
|
||||
pil_thermal = Image.fromarray(thermal_image)
|
||||
|
||||
# Apply zoom by scaling to a larger size than the camera
|
||||
scaled_width = int(CAMERA_WIDTH * THERMAL_ZOOM)
|
||||
scaled_height = int(CAMERA_HEIGHT * THERMAL_ZOOM)
|
||||
pil_thermal = pil_thermal.resize((scaled_width, scaled_height), Image.BICUBIC)
|
||||
|
||||
# Crop the center to match camera size (this creates the zoom effect)
|
||||
thermal_array = np.array(pil_thermal)
|
||||
crop_x = (scaled_width - CAMERA_WIDTH) // 2
|
||||
crop_y = (scaled_height - CAMERA_HEIGHT) // 2
|
||||
thermal_cropped = thermal_array[crop_y:crop_y+CAMERA_HEIGHT, crop_x:crop_x+CAMERA_WIDTH]
|
||||
|
||||
return thermal_cropped, min_temp, max_temp, avg_temp
|
||||
|
||||
def blend_images(camera_image, thermal_image, opacity):
|
||||
"""Blend camera and thermal images with position offset"""
|
||||
# Create a canvas the same size as the camera image
|
||||
canvas = camera_image.copy()
|
||||
|
||||
# Calculate position with offset
|
||||
x_offset = 0
|
||||
y_offset = 0
|
||||
|
||||
# Ensure the thermal image fits within bounds
|
||||
x_start = max(0, x_offset)
|
||||
y_start = max(0, y_offset)
|
||||
x_end = min(camera_image.shape[1], x_offset + thermal_image.shape[1])
|
||||
y_end = min(camera_image.shape[0], y_offset + thermal_image.shape[0])
|
||||
|
||||
# Calculate the corresponding region in the thermal image
|
||||
thermal_x_start = max(0, -x_offset)
|
||||
thermal_y_start = max(0, -y_offset)
|
||||
thermal_x_end = thermal_x_start + (x_end - x_start)
|
||||
thermal_y_end = thermal_y_start + (y_end - y_start)
|
||||
|
||||
# Blend only the overlapping region
|
||||
if x_end > x_start and y_end > y_start:
|
||||
canvas[y_start:y_end, x_start:x_end] = (
|
||||
canvas[y_start:y_end, x_start:x_end] * (1 - opacity) +
|
||||
thermal_image[thermal_y_start:thermal_y_end, thermal_x_start:thermal_x_end] * opacity
|
||||
)
|
||||
|
||||
return canvas.astype(np.uint8)
|
||||
|
||||
def add_temperature_scale(image, colormap):
|
||||
"""Add temperature scale bar to the image"""
|
||||
# Create scale bar
|
||||
scale_height = 20
|
||||
scale_width = 200
|
||||
scale_x = image.shape[1] - scale_width - 20
|
||||
scale_y = 90 # Moved down to make room for buttons
|
||||
|
||||
# Draw temperature gradient
|
||||
for i in range(scale_width):
|
||||
temp_normalized = i / scale_width
|
||||
color_idx = int(temp_normalized * 255)
|
||||
color = colormap[color_idx]
|
||||
cv2.line(image,
|
||||
(scale_x + i, scale_y),
|
||||
(scale_x + i, scale_y + scale_height),
|
||||
color.tolist(), 1)
|
||||
|
||||
# Add temperature labels
|
||||
cv2.putText(image, f"{MIN_TEMP:.0f}C",
|
||||
(scale_x - 35, scale_y + 15),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1)
|
||||
cv2.putText(image, f"{MAX_TEMP:.0f}C",
|
||||
(scale_x + scale_width + 5, scale_y + 15),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1)
|
||||
|
||||
# Draw border around scale
|
||||
cv2.rectangle(image,
|
||||
(scale_x - 1, scale_y - 1),
|
||||
(scale_x + scale_width + 1, scale_y + scale_height + 1),
|
||||
(255, 255, 255), 1)
|
||||
|
||||
# ============= MAIN LOOP =============
|
||||
print("Starting thermal camera overlay...")
|
||||
print("Use Up/Down keys to increase/decrease max temp")
|
||||
print("Use Left/Right keys to increase/decrease min temp")
|
||||
print("Use +/- keys to increase/decrease overlay opacity")
|
||||
print("Use Q key to exit")
|
||||
|
||||
cv2.namedWindow('Thermal Overlay', cv2.WINDOW_NORMAL)
|
||||
cv2.resizeWindow('Thermal Overlay', WINDOW_WIDTH, WINDOW_HEIGHT)
|
||||
|
||||
# Temperature statistics
|
||||
temp_stats = {"min": 0, "max": 0, "avg": 0}
|
||||
last_thermal_colored = None
|
||||
|
||||
try:
|
||||
while True:
|
||||
|
||||
# Read thermal data (only every SKIP_FRAMES frames)
|
||||
if frame_counter % SKIP_FRAMES == 0:
|
||||
try:
|
||||
mlx.getFrame(thermal_frame)
|
||||
# Process thermal data to colored image
|
||||
last_thermal_colored, temp_stats["min"], temp_stats["max"], temp_stats["avg"] = process_thermal_frame(thermal_frame, the_colormap) # pylint: disable=line-too-long
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
print(f"Thermal read error: {e}")
|
||||
|
||||
frame_counter += 1
|
||||
|
||||
# Use the last processed thermal frame
|
||||
if last_thermal_colored is not None:
|
||||
thermal_colored = last_thermal_colored
|
||||
else:
|
||||
# Create a blank thermal image if we don't have one yet
|
||||
thermal_colored = np.zeros((CAMERA_HEIGHT, CAMERA_WIDTH, 3), dtype=np.uint8)
|
||||
|
||||
# Capture camera frame
|
||||
camera_frame = picam2.capture_array()
|
||||
|
||||
# Crop the camera frame to match thermal coverage area
|
||||
camera_cropped = camera_frame[
|
||||
CAMERA_CROP_TOP:CAMERA_HEIGHT-CAMERA_CROP_BOTTOM,
|
||||
CAMERA_CROP_LEFT:CAMERA_WIDTH-CAMERA_CROP_RIGHT
|
||||
]
|
||||
|
||||
# Resize cropped camera back to full display size
|
||||
camera_resized = cv2.resize(camera_cropped, (CAMERA_WIDTH, CAMERA_HEIGHT),
|
||||
interpolation=cv2.INTER_LINEAR)
|
||||
|
||||
# Blend camera and thermal images (now both are aligned)
|
||||
overlay_image = blend_images(camera_resized, thermal_colored, THERMAL_OPACITY)
|
||||
|
||||
# Add temperature scale
|
||||
add_temperature_scale(overlay_image, the_colormap)
|
||||
|
||||
# Add status text with temperature statistics and FPS
|
||||
status_text = f"Range: {MIN_TEMP:.0f}-{MAX_TEMP:.0f}C | Opacity: {THERMAL_OPACITY:.1f} | "
|
||||
status_text += f"Min: {temp_stats['min']:.1f}C | Max: {temp_stats['max']:.1f}C | Avg: {temp_stats['avg']:.1f}C | " # pylint: disable=line-too-long
|
||||
cv2.putText(overlay_image, status_text,
|
||||
(10, overlay_image.shape[0] - 10),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 2)
|
||||
|
||||
# Display the image
|
||||
cv2.imshow('Thermal Overlay', overlay_image)
|
||||
|
||||
# Check if window was closed
|
||||
if cv2.getWindowProperty('Thermal Overlay', cv2.WND_PROP_VISIBLE) < 1:
|
||||
break
|
||||
|
||||
key_action = cv2.waitKey(1) & 0xFF
|
||||
if key_action == ord('q'):
|
||||
raise KeyboardInterrupt
|
||||
if key_action == 82:
|
||||
MAX_TEMP = min(MAX_TEMP + 1, 100)
|
||||
print(f"Max temp: {MAX_TEMP:.1f}C")
|
||||
elif key_action == 84:
|
||||
MAX_TEMP = max(MAX_TEMP - 1, MIN_TEMP + 1)
|
||||
print(f"Max temp: {MAX_TEMP:.1f}C")
|
||||
elif key_action == 81:
|
||||
MIN_TEMP = max(MIN_TEMP - 1, -20)
|
||||
print(f"Min temp: {MIN_TEMP:.1f}C")
|
||||
elif key_action == 83:
|
||||
MIN_TEMP = min(MIN_TEMP + 1, MAX_TEMP - 1)
|
||||
print(f"Min temp: {MIN_TEMP:.1f}C")
|
||||
elif key_action == ord('+'):
|
||||
THERMAL_OPACITY = min(THERMAL_OPACITY + 0.1, 1.0)
|
||||
print(f"Opacity: {THERMAL_OPACITY:.1f}")
|
||||
elif key_action == ord('-'):
|
||||
THERMAL_OPACITY = max(THERMAL_OPACITY - 0.1, 0.0)
|
||||
print(f"Opacity: {THERMAL_OPACITY:.1f}")
|
||||
elif key_action == ord('z'):
|
||||
THERMAL_OPACITY = not THERMAL_OPACITY
|
||||
print(f"Opacity: {THERMAL_OPACITY:.1f}")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\nShutting down...")
|
||||
|
||||
finally:
|
||||
print("Cleaning up...")
|
||||
cv2.destroyAllWindows()
|
||||
cv2.waitKey(1)
|
||||
picam2.stop()
|
||||
Loading…
Reference in a new issue