PyGamer Thermal Camera Source Code
Download the project's source files and copy them to the PyGamer's CIRCUITPY root directory, including the fonts and index_to_rgb folders.
In the code window below, click the link Download Project Bundle. This will download a zip file containing the code (4 .py files), the index_to_rgb folder, needed library files and the font folder.
The zip folder contains the following folders and files:
-
fonts folder
- OpenSans-9.bdf font file
-
index_to_rgb folder
- iron_spectrum.py color converter method file
- code.py main thermal camera code
- thermal_cam_config.py start-up default settings
- thermal_cam_converters.py temperature converter helpers
- thermal_cam_splash.bmp startup screen graphic
-
A lib folder containing these required libraries:
- adafruit_amg88xx
- adafruit_bitmap_font
- adafruit_display_text
- adafruit_display_shapes
- adafruit_register
- neopixel
Here's the main CircuitPython code for the Thermal Camera. It's contained in the project zip folder as code.py. Copy this to the main (root) folder of the CIRCUITPY drive that appears when your PyGamer is connected to your computer via a known good USB cable.
# SPDX-FileCopyrightText: 2021 Jan Goolsbey for Adafruit Industries # SPDX-License-Identifier: MIT # Thermal_Cam_v70_PyBadge_code.py # 2021-12-21 v7.0 # CircuitPython v7.x compatible import time import board import busio import gc import ulab import displayio import neopixel from analogio import AnalogIn from digitalio import DigitalInOut from simpleio import map_range, tone from adafruit_display_text.label import Label from adafruit_bitmap_font import bitmap_font from adafruit_display_shapes.rect import Rect import adafruit_amg88xx from gamepadshift import GamePadShift from index_to_rgb.iron_spectrum import index_to_rgb from thermal_cam_converters import celsius_to_fahrenheit, fahrenheit_to_celsius from thermal_cam_config import ALARM_F, MIN_RANGE_F, MAX_RANGE_F, SELFIE # Instantiate display, joystick, speaker, and neopixels display = board.DISPLAY # Load the text font from the fonts folder font_0 = bitmap_font.load_font("/fonts/OpenSans-9.bdf") if hasattr(board, "JOYSTICK_X"): has_joystick = True # PyGamer with joystick joystick_x = AnalogIn(board.JOYSTICK_X) joystick_y = AnalogIn(board.JOYSTICK_Y) else: has_joystick = False # PyBadge with buttons speaker_enable = DigitalInOut(board.SPEAKER_ENABLE) speaker_enable.switch_to_output(value=True) pixels = neopixel.NeoPixel(board.NEOPIXEL, 5, pixel_order=neopixel.GRB) pixels.brightness = 0.25 # Set NeoPixel brightness pixels.fill(0x000000) # Clear all NeoPixels # Define and instantiate front panel buttons BUTTON_LEFT = 0b10000000 BUTTON_UP = 0b01000000 BUTTON_DOWN = 0b00100000 BUTTON_RIGHT = 0b00010000 BUTTON_SELECT = 0b00001000 BUTTON_START = 0b00000100 BUTTON_A = 0b00000010 BUTTON_B = 0b00000001 panel = GamePadShift( DigitalInOut(board.BUTTON_CLOCK), DigitalInOut(board.BUTTON_OUT), DigitalInOut(board.BUTTON_LATCH), ) # Establish I2C interface for the AMG8833 Thermal Camera i2c = busio.I2C(board.SCL, board.SDA, frequency=400000) amg8833 = adafruit_amg88xx.AMG88XX(i2c) # Display splash graphics splash = displayio.Group(scale=display.width // 160) bitmap = displayio.OnDiskBitmap("/thermal_cam_splash.bmp") splash.append(displayio.TileGrid(bitmap, pixel_shader=bitmap.pixel_shader)) board.DISPLAY.show(splash) time.sleep(0.1) # Allow the splash to display # Set up ulab arrays n = 8 # Thermal sensor grid axis size; AMG8833 sensor is 8x8 sensor_data = ulab.numpy.array(range(n * n)).reshape((n, n)) # Color index narray grid_data = ulab.numpy.zeros(((2 * n) - 1, (2 * n) - 1)) # 15x15 color index narray histogram = ulab.numpy.zeros((2 * n) - 1) # Histogram accumulation narray # Convert default alarm and min/max range values from config file ALARM_C = fahrenheit_to_celsius(ALARM_F) MIN_RANGE_C = fahrenheit_to_celsius(MIN_RANGE_F) MAX_RANGE_C = fahrenheit_to_celsius(MAX_RANGE_F) # The board's integral display size WIDTH = display.width HEIGHT = display.height GRID_AXIS = (2 * n) - 1 # Number of cells along the grid x or y axis GRID_SIZE = HEIGHT # Maximum number of pixels for a square grid GRID_X_OFFSET = WIDTH - GRID_SIZE # Right-align grid with display boundary CELL_SIZE = GRID_SIZE // GRID_AXIS # Size of a grid cell in pixels PALETTE_SIZE = 100 # Number of colors in spectral palette (must be > 0) # Default colors for temperature value sidebar BLACK = 0x000000 RED = 0xFF0000 YELLOW = 0xFFFF00 CYAN = 0x00FFFF BLUE = 0x0000FF WHITE = 0xFFFFFF # Text colors for setup helper's on-screen parameters param_colors = [("ALARM", WHITE), ("RANGE", RED), ("RANGE", CYAN)] # ### Helpers ### def play_tone(freq=440, duration=0.01): tone(board.A0, freq, duration) return def flash_status(text="", duration=0.05): # Flash status message once status_label.color = WHITE status_label.text = text time.sleep(duration) status_label.color = BLACK time.sleep(duration) status_label.text = "" return def spectrum(): # Load a test spectrum into the grid_data array for row in range(0, GRID_AXIS): for col in range(0, GRID_AXIS): grid_data[row][col] = ((row * GRID_AXIS) + col) * 1 / 235 return def update_image_frame(selfie=False): # Get camera data and update display for row in range(0, GRID_AXIS): for col in range(0, GRID_AXIS): if selfie: color_index = grid_data[GRID_AXIS - 1 - row][col] else: color_index = grid_data[GRID_AXIS - 1 - row][GRID_AXIS - 1 - col] color = index_to_rgb(round(color_index * PALETTE_SIZE, 0) / PALETTE_SIZE) if color != image_group[((row * GRID_AXIS) + col)].fill: image_group[((row * GRID_AXIS) + col)].fill = color return def update_histo_frame(): # Calculate and display histogram min_histo.text = str(MIN_RANGE_F) # Display histogram legend max_histo.text = str(MAX_RANGE_F) histogram = ulab.numpy.zeros(GRID_AXIS) # Clear histogram accumulation array for row in range(0, GRID_AXIS): # Collect camera data and calculate histo for col in range(0, GRID_AXIS): histo_index = int(map_range(grid_data[col, row], 0, 1, 0, GRID_AXIS - 1)) histogram[histo_index] = histogram[histo_index] + 1 histo_scale = ulab.numpy.max(histogram) / (GRID_AXIS - 1) if histo_scale <= 0: histo_scale = 1 for col in range(0, GRID_AXIS): # Display histogram for row in range(0, GRID_AXIS): if histogram[col] / histo_scale > GRID_AXIS - 1 - row: image_group[((row * GRID_AXIS) + col)].fill = index_to_rgb( round((col / GRID_AXIS), 3) ) else: image_group[((row * GRID_AXIS) + col)].fill = BLACK return def ulab_bilinear_interpolation(): # 2x bilinear interpolation # Upscale sensor data array; by @v923z and @David.Glaude grid_data[1::2, ::2] = sensor_data[:-1, :] grid_data[1::2, ::2] += sensor_data[1:, :] grid_data[1::2, ::2] /= 2 grid_data[::, 1::2] = grid_data[::, :-1:2] grid_data[::, 1::2] += grid_data[::, 2::2] grid_data[::, 1::2] /= 2 return def setup_mode(): # Set alarm threshold and minimum/maximum range values status_label.color = WHITE status_label.text = "-SET-" ave_label.color = BLACK # Turn off average label and value display ave_value.color = BLACK max_value.text = str(MAX_RANGE_F) # Display maximum range value min_value.text = str(MIN_RANGE_F) # Display minimum range value time.sleep(0.8) # Show SET status text before setting parameters status_label.text = "" # Clear status text param_index = 0 # Reset index of parameter to set # Select parameter to set buttons = panel.get_pressed() while not buttons & BUTTON_START: buttons = panel.get_pressed() while (not buttons & BUTTON_A) and (not buttons & BUTTON_START): up, down = move_buttons(joystick=has_joystick) if up: param_index = param_index - 1 if down: param_index = param_index + 1 param_index = max(0, min(2, param_index)) status_label.text = param_colors[param_index][0] image_group[param_index + 226].color = BLACK status_label.color = BLACK time.sleep(0.25) image_group[param_index + 226].color = param_colors[param_index][1] status_label.color = WHITE time.sleep(0.25) buttons = panel.get_pressed() buttons = panel.get_pressed() if buttons & BUTTON_A: # Hold (button A) pressed play_tone(1319, 0.030) # E6 while buttons & BUTTON_A: # Wait for button release buttons = panel.get_pressed() time.sleep(0.1) # Adjust parameter value param_value = int(image_group[param_index + 230].text) buttons = panel.get_pressed() while (not buttons & BUTTON_A) and (not buttons & BUTTON_START): up, down = move_buttons(joystick=has_joystick) if up: param_value = param_value + 1 if down: param_value = param_value - 1 param_value = max(32, min(157, param_value)) image_group[param_index + 230].text = str(param_value) image_group[param_index + 230].color = BLACK status_label.color = BLACK time.sleep(0.05) image_group[param_index + 230].color = param_colors[param_index][1] status_label.color = WHITE time.sleep(0.2) buttons = panel.get_pressed() buttons = panel.get_pressed() if buttons & BUTTON_A: # Button A pressed play_tone(1319, 0.030) # E6 while buttons & BUTTON_A: # Wait for button release buttons = panel.get_pressed() time.sleep(0.1) # Exit setup process buttons = panel.get_pressed() if buttons & BUTTON_START: # Start button pressed play_tone(784, 0.030) # G5 while buttons & BUTTON_START: # Wait for button release buttons = panel.get_pressed() time.sleep(0.1) status_label.text = "RESUME" time.sleep(0.5) status_label.text = "" # Display average label and value ave_label.color = YELLOW ave_value.color = YELLOW return int(alarm_value.text), int(max_value.text), int(min_value.text) def move_buttons(joystick=False): # Read position buttons and joystick move_u = move_d = False if joystick: # For PyGamer: interpret joystick as buttons if joystick_y.value < 20000: move_u = True elif joystick_y.value > 44000: move_d = True else: # For PyBadge read the buttons buttons = panel.get_pressed() if buttons & BUTTON_UP: move_u = True if buttons & BUTTON_DOWN: move_d = True return move_u, move_d play_tone(440, 0.1) # A4 play_tone(880, 0.1) # A5 # ### Define the display group ### t0 = time.monotonic() # Time marker: Define Display Elements image_group = displayio.Group(scale=1) # Define the foundational thermal image grid cells; image_group[0:224] # image_group[#] = image_group[ (row * GRID_AXIS) + column ] for row in range(0, GRID_AXIS): for col in range(0, GRID_AXIS): cell_x = (col * CELL_SIZE) + GRID_X_OFFSET cell_y = row * CELL_SIZE cell = Rect( x=cell_x, y=cell_y, width=CELL_SIZE, height=CELL_SIZE, fill=None, outline=None, stroke=0, ) image_group.append(cell) # Define labels and values status_label = Label(font_0, text="", color=None) status_label.anchor_point = (0.5, 0.5) status_label.anchored_position = ((WIDTH // 2) + (GRID_X_OFFSET // 2), HEIGHT // 2) image_group.append(status_label) # image_group[225] alarm_label = Label(font_0, text="alm", color=WHITE) alarm_label.anchor_point = (0, 0) alarm_label.anchored_position = (1, 16) image_group.append(alarm_label) # image_group[226] max_label = Label(font_0, text="max", color=RED) max_label.anchor_point = (0, 0) max_label.anchored_position = (1, 46) image_group.append(max_label) # image_group[227] min_label = Label(font_0, text="min", color=CYAN) min_label.anchor_point = (0, 0) min_label.anchored_position = (1, 106) image_group.append(min_label) # image_group[228] ave_label = Label(font_0, text="ave", color=YELLOW) ave_label.anchor_point = (0, 0) ave_label.anchored_position = (1, 76) image_group.append(ave_label) # image_group[229] alarm_value = Label(font_0, text=str(ALARM_F), color=WHITE) alarm_value.anchor_point = (0, 0) alarm_value.anchored_position = (1, 5) image_group.append(alarm_value) # image_group[230] max_value = Label(font_0, text=str(MAX_RANGE_F), color=RED) max_value.anchor_point = (0, 0) max_value.anchored_position = (1, 35) image_group.append(max_value) # image_group[231] min_value = Label(font_0, text=str(MIN_RANGE_F), color=CYAN) min_value.anchor_point = (0, 0) min_value.anchored_position = (1, 95) image_group.append(min_value) # image_group[232] ave_value = Label(font_0, text="---", color=YELLOW) ave_value.anchor_point = (0, 0) ave_value.anchored_position = (1, 65) image_group.append(ave_value) # image_group[233] min_histo = Label(font_0, text="", color=None) min_histo.anchor_point = (0, 0.5) min_histo.anchored_position = (GRID_X_OFFSET, 121) image_group.append(min_histo) # image_group[234] max_histo = Label(font_0, text="", color=None) max_histo.anchor_point = (1, 0.5) max_histo.anchored_position = (WIDTH - 2, 121) image_group.append(max_histo) # image_group[235] range_histo = Label(font_0, text="-RANGE-", color=None) range_histo.anchor_point = (0.5, 0.5) range_histo.anchored_position = ((WIDTH // 2) + (GRID_X_OFFSET // 2), 121) image_group.append(range_histo) # image_group[236] # ###--- PRIMARY PROCESS SETUP ---### t1 = time.monotonic() # Time marker: Primary Process Setup fm1 = gc.mem_free() # Monitor free memory display_image = True # Image display mode; False for histogram display_hold = False # Active display mode; True to hold display display_focus = False # Standard display range; True to focus display range orig_max_range_f = 0 # Establish temporary range variables orig_min_range_f = 0 # Activate display and play welcome tone display.show(image_group) spectrum() update_image_frame() flash_status("IRON", 0.75) play_tone(880, 0.010) # A5 # ###--- PRIMARY PROCESS LOOP ---### while True: t2 = time.monotonic() # Time marker: Acquire Sensor Data if display_hold: flash_status("-HOLD-", 0.25) else: sensor = amg8833.pixels # Get sensor_data data sensor_data = ulab.numpy.array(sensor) # Copy to narray t3 = time.monotonic() # Time marker: Constrain Sensor Values for row in range(0, 8): for col in range(0, 8): sensor_data[col, row] = min(max(sensor_data[col, row], 0), 80) # Update and display alarm setting and max, min, and ave stats t4 = time.monotonic() # Time marker: Display Statistics v_max = ulab.numpy.max(sensor_data) v_min = ulab.numpy.min(sensor_data) v_ave = ulab.numpy.mean(sensor_data) alarm_value.text = str(ALARM_F) max_value.text = str(celsius_to_fahrenheit(v_max)) min_value.text = str(celsius_to_fahrenheit(v_min)) ave_value.text = str(celsius_to_fahrenheit(v_ave)) # Normalize temperature to index values and interpolate t5 = time.monotonic() # Time marker: Normalize and Interpolate sensor_data = (sensor_data - MIN_RANGE_C) / (MAX_RANGE_C - MIN_RANGE_C) grid_data[::2, ::2] = sensor_data # Copy sensor data to the grid array ulab_bilinear_interpolation() # Interpolate to produce 15x15 result # Display image or histogram t6 = time.monotonic() # Time marker: Display Image if display_image: update_image_frame(selfie=SELFIE) else: update_histo_frame() # If alarm threshold is reached, flash NeoPixels and play alarm tone if v_max >= ALARM_C: pixels.fill(RED) play_tone(880, 0.015) # A5 pixels.fill(BLACK) # See if a panel button is pressed buttons = panel.get_pressed() if buttons & BUTTON_A: # Toggle display hold (shutter) play_tone(1319, 0.030) # E6 display_hold = not display_hold while buttons & BUTTON_A: buttons = panel.get_pressed() time.sleep(0.1) if buttons & BUTTON_B: # Toggle image/histogram mode (display image) play_tone(659, 0.030) # E5 display_image = not display_image while buttons & BUTTON_B: buttons = panel.get_pressed() time.sleep(0.1) if display_image: min_histo.color = None max_histo.color = None range_histo.color = None else: min_histo.color = CYAN max_histo.color = RED range_histo.color = BLUE if buttons & BUTTON_SELECT: # Toggle focus mode (display focus) play_tone(698, 0.030) # F5 display_focus = not display_focus if display_focus: # Set range values to image min/max for focused image display orig_min_range_f = MIN_RANGE_F orig_max_range_f = MAX_RANGE_F MIN_RANGE_F = celsius_to_fahrenheit(v_min) MAX_RANGE_F = celsius_to_fahrenheit(v_max) # Update range min and max values in Celsius MIN_RANGE_C = v_min MAX_RANGE_C = v_max flash_status("FOCUS", 0.2) else: # Restore previous (original) range values for image display MIN_RANGE_F = orig_min_range_f MAX_RANGE_F = orig_max_range_f # Update range min and max values in Celsius MIN_RANGE_C = fahrenheit_to_celsius(MIN_RANGE_F) MAX_RANGE_C = fahrenheit_to_celsius(MAX_RANGE_F) flash_status("ORIG", 0.2) while buttons & BUTTON_SELECT: buttons = panel.get_pressed() time.sleep(0.1) if buttons & BUTTON_START: # Activate setup mode play_tone(784, 0.030) # G5 while buttons & BUTTON_START: buttons = panel.get_pressed() time.sleep(0.1) # Invoke startup helper; update alarm and range values ALARM_F, MAX_RANGE_F, MIN_RANGE_F = setup_mode() ALARM_C = fahrenheit_to_celsius(ALARM_F) MIN_RANGE_C = fahrenheit_to_celsius(MIN_RANGE_F) MAX_RANGE_C = fahrenheit_to_celsius(MAX_RANGE_F) t7 = time.monotonic() # Time marker: End of Primary Process gc.collect() fm7 = gc.mem_free() print("*** PyBadge/Gamer Performance Stats ***") print(f" define displayio: {(t1 - t0):6.3f} sec") print(f" startup free memory: {fm1/1000:6.3} Kb") print("") print( f" 1) data acquisition: {(t4 - t2):6.3f} rate: {(1 / (t4 - t2)):5.1f} /sec" ) print(f" 2) display stats: {(t5 - t4):6.3f}") print(f" 3) interpolate: {(t6 - t5):6.3f}") print(f" 4) display image: {(t7 - t6):6.3f}") print(f" =======") print( f"total frame: {(t7 - t2):6.3f} sec rate: {(1 / (t7 - t2)):5.1f} /sec" ) print(f" free memory: {fm7/1000:6.3} Kb") print("")
The Thermal Camera needs some helpers to convert back and forth between Celsius and Fahrenheit units. This file is contained in the project zip folder as thermal_cam_converters.py.
# SPDX-FileCopyrightText: 2020 CedarGroveStudios for Adafruit Industries # # SPDX-License-Identifier: MIT # thermal_cam_converters.py def celsius_to_fahrenheit(deg_c=None): # convert C to F; round to 1 degree C return round(((9 / 5) * deg_c) + 32) def fahrenheit_to_celsius(deg_f=None): # convert F to C; round to 1 degree F return round((deg_f - 32) * (5 / 9))
The color spectrum is calculated by the index_to_rgb
helper of the iron_spectrum.py file within the index_to_rgb folder. The helper calculates a 24-bit red, green, and blue (RGB) color value from an input value of 0 to 1.0.
# SPDX-FileCopyrightText: 2021 Cedar Grove Studios for Adafruit Industries # # SPDX-License-Identifier: MIT # iron_spectrum.py # 2021-05-27 version 1.2 # Copyright 2021 Cedar Grove Studios # Temperature Index to Iron Pseudocolor Spectrum RGB Converter Helper def map_range(x, in_min, in_max, out_min, out_max): """ Maps and constrains an input value from one range of values to another. (from adafruit_simpleio) :return: Returns value mapped to new range :rtype: float """ in_range = in_max - in_min in_delta = x - in_min if in_range != 0: mapped = in_delta / in_range elif in_delta != 0: mapped = in_delta else: mapped = 0.5 mapped *= out_max - out_min mapped += out_min if out_min <= out_max: return max(min(mapped, out_max), out_min) return min(max(mapped, out_max), out_min) def index_to_rgb(index=0, gamma=0.5): """ Converts a temperature index to an iron thermographic pseudocolor spectrum RGB value. Temperature index in range of 0.0 to 1.0. Gamma in range of 0.0 to 1.0 (1.0=linear), default 0.5 for color TFT displays. :return: Returns a 24-bit RGB value :rtype: integer """ band = index * 600 # an arbitrary spectrum band index; 0 to 600 if band < 70: # dark gray to blue red = 0.1 grn = 0.1 blu = (0.2 + (0.8 * map_range(band, 0, 70, 0.0, 1.0))) ** gamma if band >= 70 and band < 200: # blue to violet red = map_range(band, 70, 200, 0.0, 0.6) ** gamma grn = 0.0 blu = 1.0 ** gamma if band >= 200 and band < 300: # violet to red red = map_range(band, 200, 300, 0.6, 1.0) ** gamma grn = 0.0 blu = map_range(band, 200, 300, 1.0, 0.0) ** gamma if band >= 300 and band < 400: # red to orange red = 1.0 ** gamma grn = map_range(band, 300, 400, 0.0, 0.5) ** gamma blu = 0.0 if band >= 400 and band < 500: # orange to yellow red = 1.0 ** gamma grn = map_range(band, 400, 500, 0.5, 1.0) ** gamma blu = 0.0 if band >= 500: # yellow to white red = 1.0 ** gamma grn = 1.0 ** gamma blu = map_range(band, 500, 580, 0.0, 1.0) ** gamma return (int(red * 255) << 16) + (int(grn * 255) << 8) + int(blu * 255)
Finally, the power-up alarm threshold, temperature display range settings, and camera orientation are contained in the thermal_cam_config.py file. All values are in degrees Fahrenheit. A SELFIE
value of True
adjusts the image for a front-facing camera orientation; False
is used for cameras facing away from the viewer.
# SPDX-FileCopyrightText: 2021 Anne Barela for Adafruit Industries # # SPDX-License-Identifier: MIT # thermal_cam_config.py # ### Alarm and range default values in Farenheit ### ALARM_F = 120 MIN_RANGE_F = 60 MAX_RANGE_F = 120 # ### Display characteristics SELFIE = False # Rear camera view; True for front view
After copying all the project files to the PyGamer, you'll see the camera's splash graphics and a sample of the iron color spectrum. After a couple of beeps, the thermal image will appear.
The next section shows the features of the camera and how it operates.