Skip to content

Commit 85eb552

Browse files
authored
Merge pull request #3071 from adafruit/thermal_pi
adding raspberry pi thermal camera code
2 parents 235f0c4 + 31426ae commit 85eb552

File tree

1 file changed

+316
-0
lines changed
  • Raspberry_Pi_Thermal_Camera_Overlay

1 file changed

+316
-0
lines changed
Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
# SPDX-FileCopyrightText: 2025 Liz Clark for Adafruit Industries
2+
#
3+
# SPDX-License-Identifier: MIT
4+
"""
5+
Thermal Camera Overlay for Raspberry Pi 4,
6+
PiCamera 3 and STEMMA MLX90640
7+
8+
Inspired by PitFusion Thermal Imager
9+
"""
10+
11+
import time
12+
import numpy as np
13+
import cv2
14+
import board
15+
import busio
16+
import adafruit_mlx90640
17+
from picamera2 import Picamera2
18+
from PIL import Image
19+
20+
# Temperature range for thermal camera (in Celsius)
21+
MIN_TEMP = 20.0
22+
MAX_TEMP = 35.0
23+
24+
# Thermal overlay opacity (0.0 = invisible, 1.0 = fully opaque)
25+
THERMAL_OPACITY = 0.7
26+
27+
# Display window size
28+
WINDOW_WIDTH = 1280
29+
WINDOW_HEIGHT = 720
30+
31+
# Camera settings
32+
CAMERA_WIDTH = 1280
33+
CAMERA_HEIGHT = 720
34+
35+
SKIP_FRAMES = 2 # Process every Nth frame for thermal
36+
frame_counter = 0
37+
38+
# Thermal camera size
39+
THERMAL_WIDTH = 32
40+
THERMAL_HEIGHT = 24
41+
42+
# Thermal zoom factor (1.7x to compensate for FoV difference)
43+
# Thermal camera FoV: 110°x75°, Pi camera FoV: 66°x41°
44+
# Ratio: 66/110 = 0.6, so we need 1/0.6 = 1.67x zoom
45+
THERMAL_ZOOM = 1.7
46+
47+
# Camera crop settings to compensate for thermal offset
48+
# This crops the camera image to match the thermal coverage area
49+
CAMERA_CROP_LEFT = 65 # Match thermal X offset
50+
CAMERA_CROP_TOP = 85 # Match thermal Y offset
51+
CAMERA_CROP_RIGHT = 0 # No crop on right
52+
CAMERA_CROP_BOTTOM = 0 # No crop on bottom
53+
54+
# Calculate effective camera size after cropping
55+
CAMERA_CROP_WIDTH = CAMERA_WIDTH - CAMERA_CROP_LEFT - CAMERA_CROP_RIGHT
56+
CAMERA_CROP_HEIGHT = CAMERA_HEIGHT - CAMERA_CROP_TOP - CAMERA_CROP_BOTTOM
57+
58+
# ============= SETUP THERMAL CAMERA =============
59+
print("Setting up thermal camera...")
60+
i2c = busio.I2C(board.SCL, board.SDA)
61+
mlx = adafruit_mlx90640.MLX90640(i2c)
62+
mlx.refresh_rate = adafruit_mlx90640.RefreshRate.REFRESH_4_HZ
63+
64+
# Create array to hold thermal data
65+
thermal_frame = np.zeros(768, dtype=np.float32)
66+
67+
# ============= SETUP REGULAR CAMERA =============
68+
print("Setting up Pi camera...")
69+
picam2 = Picamera2()
70+
camera_config = picam2.create_preview_configuration(
71+
main={"size": (CAMERA_WIDTH, CAMERA_HEIGHT), "format": "RGB888"},
72+
buffer_count=2, # Reduce buffer count for lower latency
73+
queue=False # Don't queue frames
74+
)
75+
picam2.configure(camera_config)
76+
picam2.start()
77+
picam2.set_controls({"ExposureTime": 20000, "AnalogueGain": 1.0})
78+
time.sleep(2)
79+
80+
# ============= CREATE THERMAL COLORMAP =============
81+
def create_thermal_colormap():
82+
"""Create a colormap for thermal visualization"""
83+
# Define color points (blue -> cyan -> green -> yellow -> orange -> red)
84+
colors = np.array([
85+
[0, 0, 64], # Dark blue (cold)
86+
[0, 0, 255], # Blue
87+
[0, 255, 255], # Cyan
88+
[0, 255, 0], # Green
89+
[255, 255, 0], # Yellow
90+
[255, 128, 0], # Orange
91+
[255, 0, 0], # Red (hot)
92+
], dtype=np.uint8)
93+
94+
# Create smooth gradient between colors
95+
colormap = np.zeros((256, 3), dtype=np.uint8)
96+
positions = np.linspace(0, len(colors)-1, 256)
97+
98+
for i in range(256):
99+
pos = positions[i]
100+
idx = int(pos)
101+
frac = pos - idx
102+
103+
if idx >= len(colors) - 1:
104+
colormap[i] = colors[-1]
105+
else:
106+
colormap[i] = (1 - frac) * colors[idx] + frac * colors[idx + 1]
107+
108+
colormap = colormap[::-1] # Reverse the colormap
109+
return colormap
110+
the_colormap = create_thermal_colormap()
111+
112+
# ============= HELPER FUNCTIONS =============
113+
def process_thermal_frame(thermal_data, colormap):
114+
"""Convert thermal data to colored image"""
115+
# Calculate temperature statistics
116+
min_temp = np.min(thermal_data)
117+
max_temp = np.max(thermal_data)
118+
avg_temp = np.mean(thermal_data)
119+
if min_temp < -100:
120+
min_temp = MIN_TEMP
121+
avg_temp = (MIN_TEMP + MAX_TEMP) / 2
122+
123+
# Normalize temperature data to 0-255 range
124+
normalized = np.clip(
125+
(thermal_data - MIN_TEMP) / (MAX_TEMP - MIN_TEMP) * 255,
126+
0, 255
127+
).astype(np.uint8)
128+
129+
# Apply colormap
130+
colored = colormap[normalized]
131+
132+
# Reshape to 2D image (24x32x3)
133+
thermal_image = colored.reshape(THERMAL_HEIGHT, THERMAL_WIDTH, 3)
134+
135+
# Flip horizontally to match camera view
136+
thermal_image = np.fliplr(thermal_image)
137+
138+
# Scale up to camera size using PIL for smooth interpolation
139+
pil_thermal = Image.fromarray(thermal_image)
140+
141+
# Apply zoom by scaling to a larger size than the camera
142+
scaled_width = int(CAMERA_WIDTH * THERMAL_ZOOM)
143+
scaled_height = int(CAMERA_HEIGHT * THERMAL_ZOOM)
144+
pil_thermal = pil_thermal.resize((scaled_width, scaled_height), Image.BICUBIC)
145+
146+
# Crop the center to match camera size (this creates the zoom effect)
147+
thermal_array = np.array(pil_thermal)
148+
crop_x = (scaled_width - CAMERA_WIDTH) // 2
149+
crop_y = (scaled_height - CAMERA_HEIGHT) // 2
150+
thermal_cropped = thermal_array[crop_y:crop_y+CAMERA_HEIGHT, crop_x:crop_x+CAMERA_WIDTH]
151+
152+
return thermal_cropped, min_temp, max_temp, avg_temp
153+
154+
def blend_images(camera_image, thermal_image, opacity):
155+
"""Blend camera and thermal images with position offset"""
156+
# Create a canvas the same size as the camera image
157+
canvas = camera_image.copy()
158+
159+
# Calculate position with offset
160+
x_offset = 0
161+
y_offset = 0
162+
163+
# Ensure the thermal image fits within bounds
164+
x_start = max(0, x_offset)
165+
y_start = max(0, y_offset)
166+
x_end = min(camera_image.shape[1], x_offset + thermal_image.shape[1])
167+
y_end = min(camera_image.shape[0], y_offset + thermal_image.shape[0])
168+
169+
# Calculate the corresponding region in the thermal image
170+
thermal_x_start = max(0, -x_offset)
171+
thermal_y_start = max(0, -y_offset)
172+
thermal_x_end = thermal_x_start + (x_end - x_start)
173+
thermal_y_end = thermal_y_start + (y_end - y_start)
174+
175+
# Blend only the overlapping region
176+
if x_end > x_start and y_end > y_start:
177+
canvas[y_start:y_end, x_start:x_end] = (
178+
canvas[y_start:y_end, x_start:x_end] * (1 - opacity) +
179+
thermal_image[thermal_y_start:thermal_y_end, thermal_x_start:thermal_x_end] * opacity
180+
)
181+
182+
return canvas.astype(np.uint8)
183+
184+
def add_temperature_scale(image, colormap):
185+
"""Add temperature scale bar to the image"""
186+
# Create scale bar
187+
scale_height = 20
188+
scale_width = 200
189+
scale_x = image.shape[1] - scale_width - 20
190+
scale_y = 90 # Moved down to make room for buttons
191+
192+
# Draw temperature gradient
193+
for i in range(scale_width):
194+
temp_normalized = i / scale_width
195+
color_idx = int(temp_normalized * 255)
196+
color = colormap[color_idx]
197+
cv2.line(image,
198+
(scale_x + i, scale_y),
199+
(scale_x + i, scale_y + scale_height),
200+
color.tolist(), 1)
201+
202+
# Add temperature labels
203+
cv2.putText(image, f"{MIN_TEMP:.0f}C",
204+
(scale_x - 35, scale_y + 15),
205+
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1)
206+
cv2.putText(image, f"{MAX_TEMP:.0f}C",
207+
(scale_x + scale_width + 5, scale_y + 15),
208+
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1)
209+
210+
# Draw border around scale
211+
cv2.rectangle(image,
212+
(scale_x - 1, scale_y - 1),
213+
(scale_x + scale_width + 1, scale_y + scale_height + 1),
214+
(255, 255, 255), 1)
215+
216+
# ============= MAIN LOOP =============
217+
print("Starting thermal camera overlay...")
218+
print("Use Up/Down keys to increase/decrease max temp")
219+
print("Use Left/Right keys to increase/decrease min temp")
220+
print("Use +/- keys to increase/decrease overlay opacity")
221+
print("Use Q key to exit")
222+
223+
cv2.namedWindow('Thermal Overlay', cv2.WINDOW_NORMAL)
224+
cv2.resizeWindow('Thermal Overlay', WINDOW_WIDTH, WINDOW_HEIGHT)
225+
226+
# Temperature statistics
227+
temp_stats = {"min": 0, "max": 0, "avg": 0}
228+
last_thermal_colored = None
229+
230+
try:
231+
while True:
232+
233+
# Read thermal data (only every SKIP_FRAMES frames)
234+
if frame_counter % SKIP_FRAMES == 0:
235+
try:
236+
mlx.getFrame(thermal_frame)
237+
# Process thermal data to colored image
238+
last_thermal_colored, temp_stats["min"], temp_stats["max"], temp_stats["avg"] = process_thermal_frame(thermal_frame, the_colormap) # pylint: disable=line-too-long
239+
except Exception as e: # pylint: disable=broad-except
240+
print(f"Thermal read error: {e}")
241+
242+
frame_counter += 1
243+
244+
# Use the last processed thermal frame
245+
if last_thermal_colored is not None:
246+
thermal_colored = last_thermal_colored
247+
else:
248+
# Create a blank thermal image if we don't have one yet
249+
thermal_colored = np.zeros((CAMERA_HEIGHT, CAMERA_WIDTH, 3), dtype=np.uint8)
250+
251+
# Capture camera frame
252+
camera_frame = picam2.capture_array()
253+
254+
# Crop the camera frame to match thermal coverage area
255+
camera_cropped = camera_frame[
256+
CAMERA_CROP_TOP:CAMERA_HEIGHT-CAMERA_CROP_BOTTOM,
257+
CAMERA_CROP_LEFT:CAMERA_WIDTH-CAMERA_CROP_RIGHT
258+
]
259+
260+
# Resize cropped camera back to full display size
261+
camera_resized = cv2.resize(camera_cropped, (CAMERA_WIDTH, CAMERA_HEIGHT),
262+
interpolation=cv2.INTER_LINEAR)
263+
264+
# Blend camera and thermal images (now both are aligned)
265+
overlay_image = blend_images(camera_resized, thermal_colored, THERMAL_OPACITY)
266+
267+
# Add temperature scale
268+
add_temperature_scale(overlay_image, the_colormap)
269+
270+
# Add status text with temperature statistics and FPS
271+
status_text = f"Range: {MIN_TEMP:.0f}-{MAX_TEMP:.0f}C | Opacity: {THERMAL_OPACITY:.1f} | "
272+
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
273+
cv2.putText(overlay_image, status_text,
274+
(10, overlay_image.shape[0] - 10),
275+
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 2)
276+
277+
# Display the image
278+
cv2.imshow('Thermal Overlay', overlay_image)
279+
280+
# Check if window was closed
281+
if cv2.getWindowProperty('Thermal Overlay', cv2.WND_PROP_VISIBLE) < 1:
282+
break
283+
284+
key_action = cv2.waitKey(1) & 0xFF
285+
if key_action == ord('q'):
286+
raise KeyboardInterrupt
287+
if key_action == 82:
288+
MAX_TEMP = min(MAX_TEMP + 1, 100)
289+
print(f"Max temp: {MAX_TEMP:.1f}C")
290+
elif key_action == 84:
291+
MAX_TEMP = max(MAX_TEMP - 1, MIN_TEMP + 1)
292+
print(f"Max temp: {MAX_TEMP:.1f}C")
293+
elif key_action == 81:
294+
MIN_TEMP = max(MIN_TEMP - 1, -20)
295+
print(f"Min temp: {MIN_TEMP:.1f}C")
296+
elif key_action == 83:
297+
MIN_TEMP = min(MIN_TEMP + 1, MAX_TEMP - 1)
298+
print(f"Min temp: {MIN_TEMP:.1f}C")
299+
elif key_action == ord('+'):
300+
THERMAL_OPACITY = min(THERMAL_OPACITY + 0.1, 1.0)
301+
print(f"Opacity: {THERMAL_OPACITY:.1f}")
302+
elif key_action == ord('-'):
303+
THERMAL_OPACITY = max(THERMAL_OPACITY - 0.1, 0.0)
304+
print(f"Opacity: {THERMAL_OPACITY:.1f}")
305+
elif key_action == ord('z'):
306+
THERMAL_OPACITY = not THERMAL_OPACITY
307+
print(f"Opacity: {THERMAL_OPACITY:.1f}")
308+
309+
except KeyboardInterrupt:
310+
print("\nShutting down...")
311+
312+
finally:
313+
print("Cleaning up...")
314+
cv2.destroyAllWindows()
315+
cv2.waitKey(1)
316+
picam2.stop()

0 commit comments

Comments
 (0)