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:
- code.py main thermal camera code
-
fonts folder
- OpenSans-9.bdf font file
-
index_to_rgb folder
- iron_spectrum.py color converter method file
-
A lib folder containing these required libraries:
- adafruit_amg88xx
- adafruit_bitmap_font
- adafruit_bus_device
- adafruit_display_shapes
- adafruit_display_text
- adafruit_pixelbuf
- adafruit_register
- neopixel
- simpleio
- thermalcamera_config.py start-up default settings
- thermalcamera_converters.py temperature converter helpers
- thermalcamera_splash.bmp startup screen graphic
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: 2022 Jan Goolsbey for Adafruit Industries # SPDX-License-Identifier: MIT """ `thermalcamera` ================================================================================ PyGamer/PyBadge Thermal Camera Project """ import time import gc import board import keypad import busio from ulab import numpy as np 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 index_to_rgb.iron import index_to_rgb from thermalcamera_converters import celsius_to_fahrenheit, fahrenheit_to_celsius from thermalcamera_config import ALARM_F, MIN_RANGE_F, MAX_RANGE_F, SELFIE # Instantiate the integral display and define its size display = board.DISPLAY display.brightness = 1.0 WIDTH = display.width HEIGHT = display.height # Load the text font from the fonts folder font_0 = bitmap_font.load_font("/fonts/OpenSans-9.bdf") # Instantiate the joystick if available if hasattr(board, "JOYSTICK_X"): # PyGamer with joystick HAS_JOYSTICK = True joystick_x = AnalogIn(board.JOYSTICK_X) joystick_y = AnalogIn(board.JOYSTICK_Y) else: # PyBadge with buttons HAS_JOYSTICK = False # PyBadge with buttons # Enable the speaker DigitalInOut(board.SPEAKER_ENABLE).switch_to_output(value=True) # Instantiate and clear the NeoPixels pixels = neopixel.NeoPixel(board.NEOPIXEL, 5, pixel_order=neopixel.GRB) pixels.brightness = 0.25 pixels.fill(0x000000) # Initialize ShiftRegisterKeys to read PyGamer/PyBadge buttons panel = keypad.ShiftRegisterKeys( clock=board.BUTTON_CLOCK, data=board.BUTTON_OUT, latch=board.BUTTON_LATCH, key_count=8, value_when_pressed=True, ) # Define front panel button event values BUTTON_LEFT = 7 # LEFT button BUTTON_UP = 6 # UP button BUTTON_DOWN = 5 # DOWN button BUTTON_RIGHT = 4 # RIGHT button BUTTON_FOCUS = 3 # SELECT button BUTTON_SET = 2 # START button BUTTON_HOLD = 1 # button A BUTTON_IMAGE = 0 # button B # Initiate 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("/thermalcamera_splash.bmp") splash.append(displayio.TileGrid(bitmap, pixel_shader=bitmap.pixel_shader)) board.DISPLAY.root_group = splash # Thermal sensor grid axis size; AMG8833 sensor is 8x8 SENSOR_AXIS = 8 # Display grid parameters GRID_AXIS = (2 * SENSOR_AXIS) - 1 # Number of cells per axis GRID_SIZE = HEIGHT # Axis size (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 display colors in spectral palette (must be > 0) # Set up the 2-D sensor data narray SENSOR_DATA = np.array(range(SENSOR_AXIS**2)).reshape((SENSOR_AXIS, SENSOR_AXIS)) # Set up and load the 2-D display color index narray with a spectrum GRID_DATA = np.array(range(GRID_AXIS**2)).reshape((GRID_AXIS, GRID_AXIS)) / ( GRID_AXIS**2 ) # Set up the histogram accumulation narray # HISTOGRAM = np.zeros(GRID_AXIS) # 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) # 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 SETUP_COLORS = [("ALARM", WHITE), ("RANGE", RED), ("RANGE", CYAN)] # ### Helpers ### def play_tone(freq=440, duration=0.01): """Play a tone over the speaker""" tone(board.A0, freq, duration) 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 = "" 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 def update_histo_frame(): """Calculate and display histogram""" min_histo.text = str(MIN_RANGE_F) # Display the legend max_histo.text = str(MAX_RANGE_F) histogram = np.zeros(GRID_AXIS) # Clear histogram accumulation array # Collect camera data and calculate the histogram for _row in range(0, GRID_AXIS): 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 = np.max(histogram) / (GRID_AXIS - 1) if histo_scale <= 0: histo_scale = 1 # Display the histogram for _col in range(0, GRID_AXIS): 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 def ulab_bilinear_interpolation(): """2x bilinear interpolation to upscale the 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 # pylint: disable=too-many-branches # pylint: disable=too-many-statements def setup_mode(): """Change 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 setup_state = "SETUP" # Set initial state while setup_state == "SETUP": # Select parameter to set setup_state = "SELECT_PARAM" # Parameter selection state while setup_state == "SELECT_PARAM": param_index = max(0, min(2, param_index)) status_label.text = SETUP_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 = SETUP_COLORS[param_index][1] status_label.color = WHITE time.sleep(0.25) param_index -= get_joystick() _buttons = panel.events.get() if _buttons and _buttons.pressed: if _buttons.key_number == BUTTON_UP: # HOLD button pressed param_index = param_index - 1 if _buttons.key_number == BUTTON_DOWN: # SET button pressed param_index = param_index + 1 if _buttons.key_number == BUTTON_HOLD: # HOLD button pressed play_tone(1319, 0.030) # Musical note E6 setup_state = "ADJUST_VALUE" # Next state if _buttons.key_number == BUTTON_SET: # SET button pressed play_tone(1319, 0.030) # Musical note E6 setup_state = "EXIT" # Next state # Adjust parameter value param_value = int(image_group[param_index + 230].text) while setup_state == "ADJUST_VALUE": 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 = SETUP_COLORS[param_index][1] status_label.color = WHITE time.sleep(0.2) param_value += get_joystick() _buttons = panel.events.get() if _buttons and _buttons.pressed: if _buttons.key_number == BUTTON_UP: # HOLD button pressed param_value = param_value + 1 if _buttons.key_number == BUTTON_DOWN: # SET button pressed param_value = param_value - 1 if _buttons.key_number == BUTTON_HOLD: # HOLD button pressed play_tone(1319, 0.030) # Musical note E6 setup_state = "SETUP" # Next state if _buttons.key_number == BUTTON_SET: # SET button pressed play_tone(1319, 0.030) # Musical note E6 setup_state = "EXIT" # Next state # Exit setup process 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 get_joystick(): """Read the joystick and interpret as up/down buttons (PyGamer)""" if HAS_JOYSTICK: if joystick_y.value < 20000: # Up return 1 if joystick_y.value > 44000: # Down return -1 return 0 play_tone(440, 0.1) # Musical note A4 play_tone(880, 0.1) # Musical note A5 # ### Define the display group ### mkr_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 ---### mkr_t1 = time.monotonic() # Time marker: Primary Process Setup # pylint: disable=no-member mem_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 # pylint: disable=invalid-name orig_max_range_f = 0 # Establish temporary range variables orig_min_range_f = 0 # Activate display, show preloaded sample spectrum, and play welcome tone display.root_group = image_group update_image_frame() flash_status("IRON", 0.75) play_tone(880, 0.010) # Musical note A5 # ###--- PRIMARY PROCESS LOOP ---### while True: mkr_t2 = time.monotonic() # Time marker: Acquire Sensor Data if DISPLAY_HOLD: flash_status("-HOLD-", 0.25) else: sensor = amg8833.pixels # Get sensor_data data # Put sensor data in array; limit to the range of 0, 80 SENSOR_DATA = np.clip(np.array(sensor), 0, 80) # Update and display alarm setting and max, min, and ave stats mkr_t4 = time.monotonic() # Time marker: Display Statistics v_max = np.max(SENSOR_DATA) v_min = np.min(SENSOR_DATA) v_ave = np.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 mkr_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 mkr_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) # Musical note A5 pixels.fill(BLACK) # See if a panel button is pressed buttons = panel.events.get() if buttons and buttons.pressed: if buttons.key_number == BUTTON_HOLD: # Toggle display hold (shutter) play_tone(1319, 0.030) # Musical note E6 DISPLAY_HOLD = not DISPLAY_HOLD if buttons.key_number == BUTTON_IMAGE: # Toggle image/histogram mode (display image) play_tone(659, 0.030) # Musical note E5 DISPLAY_IMAGE = not DISPLAY_IMAGE 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.key_number == BUTTON_FOCUS: # Toggle display focus mode play_tone(698, 0.030) # Musical note 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) if buttons.key_number == BUTTON_SET: # Activate setup mode play_tone(784, 0.030) # Musical note G5 # 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) mkr_t7 = time.monotonic() # Time marker: End of Primary Process gc.collect() mem_fm7 = gc.mem_free() # Print frame performance report print("*** PyBadge/Gamer Performance Stats ***") print(f" define display: {(mkr_t1 - mkr_t0):6.3f} sec") print(f" free memory: {mem_fm1 / 1000:6.3f} Kb") print("") print(" rate") print(f" 1) acquire: {(mkr_t4 - mkr_t2):6.3f} sec ", end="") print(f"{(1 / (mkr_t4 - mkr_t2)):5.1f} /sec") print(f" 2) stats: {(mkr_t5 - mkr_t4):6.3f} sec") print(f" 3) convert: {(mkr_t6 - mkr_t5):6.3f} sec") print(f" 4) display: {(mkr_t7 - mkr_t6):6.3f} sec") print(" =======") print(f"total frame: {(mkr_t7 - mkr_t2):6.3f} sec ", end="") print(f"{(1 / (mkr_t7 - mkr_t2)):5.1f} /sec") print(f" free memory: {mem_fm7 / 1000:6.3f} 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 thermalcamera_converters.py.
# SPDX-FileCopyrightText: 2022 Jan Goolsbey for Adafruit Industries # SPDX-License-Identifier: MIT """ `thermalcamera_converters` ================================================================================ Celsius-to-Fahrenheit and Fahrenheit-to-Celsius converter helpers. """ 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 iron.py helper 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: Copyright (c) 2022 JG for Cedar Grove Maker Studios # # SPDX-License-Identifier: MIT """ `cedargrove_rgb_spectrumtools.iron` ================================================================================ Temperature Index to Iron Pseudocolor Spectrum RGB Converter Helper * Author(s): JG Implementation Notes -------------------- **Hardware:** **Software and Dependencies:** * Adafruit CircuitPython firmware for the supported boards: https://circuitpython.org/downloads """ __version__ = "0.0.0+auto.0" __repo__ = "https://github.com/CedarGroveStudios/CircuitPython_RGB_SpectrumTools.git" 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) :param float x: The value to be mapped. No default. :param float in_min: The beginning of the input range. No default. :param float in_max: The end of the input range. No default. :param float out_min: The beginning of the output range. No default. :param float out_max: The end of the output range. No default. :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. :param float index: The normalized index value, range 0 to 1.0. Defaults to 0. :param float gamma: The gamma color perception value. Defaults to 0.5. :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 70 <= band < 200: # blue to violet red = map_range(band, 70, 200, 0.0, 0.6) ** gamma grn = 0.0 blu = 1.0**gamma if 200 <= 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 300 <= band < 400: # red to orange red = 1.0**gamma grn = map_range(band, 300, 400, 0.0, 0.5) ** gamma blu = 0.0 if 400 <= 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 thermalcamera_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: 2022 Jan Goolsbey for Adafruit Industries # SPDX-License-Identifier: MIT """ `thermalcamera_config` ================================================================================ Thermal Camera configuration parameters. """ # ### 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.
Page last edited January 22, 2025
Text editor powered by tinymce.