CircuitPython Code

CircuitPython Libraries

Your PyGamer should now have the latest version of CircuitPython and the collection of libraries needed for this project. Confirm that the lib folder on the PyGamer's CIRCUITPY flash drive contains these required libraries:

  • adafruit_simpleio
  • adafruit_display_text
  • adafruit_bitmap_font
  • adafruit_display_shapes
  • adafruit_amg88xx
  • adafruit_pybadger

PyGamer Thermal Camera Source Code

Download the project's source files and copy them to the PyGamer's CIRCUITPY root directory, with the exception of the OpenSans-9.bdf font file which should be placed into a fonts folder.

In the code window below, click the link Download: Project Zip. This will download a zip file containing the code (4 .py files) and the font file used for the display.

temperature___humidity_circuitpy_directory_wht.jpg
The PyGamer's CIRCUITPY drive contents.

The zip folder contains the following folders and files:

  • fonts folder
    • OpenSans-9.bdf  font file
  • code.py  main thermal camera code
  • thermal_cam_config.py  power-up default settings
  • thermal_cam_converters.py  temperature unit converter helpers
  • thermal_cam_splash.bmp  startup screen graphic

Here's the main CircuitPython code for the Thermal Camera. It's also contained in the project zip folder as code.py .

# Thermal_Cam_v32.py
# 2020-01-29 v3.2
# (c) 2020 Jan Goolsbey for Adafruit Industries

import time
import board
import displayio
from simpleio import map_range
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 adafruit_pybadger import PyBadger
from thermal_cam_converters import celsius_to_fahrenheit, fahrenheit_to_celsius
# Load default alarm and min/max range values list from config file
from thermal_cam_config import ALARM_F, MIN_RANGE_F, MAX_RANGE_F

# Establish panel instance and check for joystick
panel = PyBadger(pixels_brightness=0.1)  # Set NeoPixel brightness
panel.pixels.fill(0)                     # Clear all NeoPixels
if hasattr(board, "JOYSTICK_X"):
    panel.has_joystick = True     # PyGamer
else: panel.has_joystick = False  # Must be PyBadge

# Establish I2C interface for the AMG8833 Thermal Camera
i2c = board.I2C()
amg8833 = adafruit_amg88xx.AMG88XX(i2c)

# Load the text font from the fonts folder
font = bitmap_font.load_font("/fonts/OpenSans-9.bdf")

# Display spash graphics and play startup tones
with open("/thermal_cam_splash.bmp", "rb") as bitmap_file:
    bitmap = displayio.OnDiskBitmap(bitmap_file)
    splash = displayio.Group()
    splash.append(displayio.TileGrid(bitmap,
                                     pixel_shader=displayio.ColorConverter()))
    board.DISPLAY.show(splash)
    time.sleep(0.1)  # Allow the splash to display
panel.play_tone(440, 0.1)  # A4
panel.play_tone(880, 0.1)  # A5

# The image sensor's design-limited temperature range
MIN_SENSOR_C = 0
MAX_SENSOR_C = 80
MIN_SENSOR_F = celsius_to_fahrenheit(MIN_SENSOR_C)
MAX_SENSOR_F = celsius_to_fahrenheit(MAX_SENSOR_C)

# 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  = board.DISPLAY.width   # 160 for PyGamer and PyBadge
HEIGHT = board.DISPLAY.height  # 128 for PyGamer and PyBadge

ELEMENT_SIZE = WIDTH // 10  # Size of element_grid blocks in pixels

# Default colors
BLACK   = 0x000000
RED     = 0xFF0000
ORANGE  = 0xFF8811
YELLOW  = 0xFFFF00
GREEN   = 0x00FF00
CYAN    = 0x00FFFF
BLUE    = 0x0000FF
VIOLET  = 0x9900FF
WHITE   = 0xFFFFFF
GRAY    = 0x444455

# Block colors for the thermal image grid
element_color = [GRAY, BLUE, GREEN, YELLOW, ORANGE, RED, VIOLET, WHITE]
# Text colors for on-screen parameters
param_list = [("ALARM", WHITE), ("RANGE", RED), ("RANGE", CYAN)]

### Helpers ###
def element_grid(col0, row0):  # Determine display coordinates for column, row
    x = int(ELEMENT_SIZE * col0 + 30)  # x coord + margin
    y = int(ELEMENT_SIZE * row0 + 1)   # y coord + margin
    return x, y  # Return display coordinates

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)
    return

def update_image_frame():  # Get camera data and display
    minimum = MAX_SENSOR_C  # Set minimum to sensor's maximum C value
    maximum = MIN_SENSOR_C  # Set maximum to sensor's minimum C value

    min_histo.text   = ""  # Clear histogram legend
    max_histo.text   = ""
    range_histo.text = ""

    sum_bucket = 0  # Clear bucket for building average value

    for row1 in range(0, 8):  # Parse camera data list and update display
        for col1 in range(0, 8):
            value = map_range(image[7 - row1][7 - col1],
                              MIN_SENSOR_C, MAX_SENSOR_C,
                              MIN_SENSOR_C, MAX_SENSOR_C)
            color_index = int(map_range(value, MIN_RANGE_C, MAX_RANGE_C, 0, 7))
            image_group[((row1 * 8) + col1) + 1].fill = element_color[color_index]
            sum_bucket = sum_bucket + value  # Calculate sum for average
            minimum = min(value, minimum)
            maximum = max(value, maximum)
    return minimum, maximum, sum_bucket

def update_histo_frame():
    minimum = MAX_SENSOR_C  # Set minimum to sensor's maximum C value
    maximum = MIN_SENSOR_C  # Set maximum to sensor's minimum C value

    min_histo.text   = str(MIN_RANGE_F)  # Display histogram legend
    max_histo.text   = str(MAX_RANGE_F)
    range_histo.text = "-RANGE-"

    sum_bucket = 0  # Clear bucket for building average value

    histo_bucket = [0, 0, 0, 0, 0, 0, 0, 0]  # Clear histogram bucket
    for row2 in range(7, -1, -1):  # Collect camera data and calculate spectrum
        for col2 in range(0, 8):
            value = map_range(image[col2][row2],
                              MIN_SENSOR_C, MAX_SENSOR_C,
                              MIN_SENSOR_C, MAX_SENSOR_C)
            histo_index = int(map_range(value, MIN_RANGE_C, MAX_RANGE_C, 0, 7))
            histo_bucket[histo_index] = histo_bucket[histo_index] + 1
            sum_bucket = sum_bucket + value  # Calculate sum for average
            minimum = min(value, minimum)
            maximum = max(value, maximum)

    for col2 in range(0, 8):  # Display histogram
        for row2 in range(0, 8):
            if histo_bucket[col2] / 8 > 7 - row2:
                image_group[((row2 * 8) + col2) + 1].fill = element_color[col2]
            else:
                image_group[((row2 * 8) + col2) + 1].fill = BLACK
    return minimum, maximum, sum_bucket

#pylint: disable=too-many-branches,too-many-statements
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
    while not panel.button.start:
        while (not panel.button.a) and (not panel.button.start):
            up, down = move_buttons(joystick=panel.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_list[param_index][0]
            image_group[param_index + 66].color = BLACK
            status_label.color = BLACK
            time.sleep(0.2)
            image_group[param_index + 66].color = param_list[param_index][1]
            status_label.color = WHITE
            time.sleep(0.2)

        if panel.button.a:  # Button A pressed
            panel.play_tone(1319, 0.030)  # E6
        while panel.button.a:  # wait for button release
            pass

        # Adjust parameter value
        param_value = int(image_group[param_index + 70].text)
        while (not panel.button.a) and (not panel.button.start):
            up, down = move_buttons(joystick=panel.has_joystick)
            if up:
                param_value = param_value + 1
            if down:
                param_value = param_value - 1
            param_value = max(MIN_SENSOR_F, min(MAX_SENSOR_F, param_value))
            image_group[param_index + 70].text = str(param_value)
            image_group[param_index + 70].color = BLACK
            status_label.color = BLACK
            time.sleep(0.05)
            image_group[param_index + 70].color = param_list[param_index][1]
            status_label.color = WHITE
            time.sleep(0.2)

        if panel.button.a:  # Button A pressed
            panel.play_tone(1319, 0.030)  # E6
        while panel.button.a:  # wait for button release
            pass

    # Exit setup process
    if panel.button.start:  # Start button pressed
        panel.play_tone(784, 0.030)  # G5
    while panel.button.start:  # wait for button release
        pass

    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)
#pylint: enable=too-many-branches,too-many-statements

def move_buttons(joystick=False):  # Read position buttons and joystick
    move_u = move_d = False
    if joystick:  # For PyGamer: interpret joystick as buttons
        if   panel.joystick[1] < 20000:
            move_u = True
        elif panel.joystick[1] > 44000:
            move_d = True
    else:  # For PyBadge read the buttons
        if panel.button.up:
            move_u = True
        if panel.button.down:
            move_d = True
    return move_u, move_d

### Define the display group ###
image_group = displayio.Group(max_size=77)

# Create a background color fill layer; image_group[0]
color_bitmap = displayio.Bitmap(WIDTH, HEIGHT, 1)
color_palette = displayio.Palette(1)
color_palette[0] = BLACK
background = displayio.TileGrid(color_bitmap, pixel_shader=color_palette,
                                x=0, y=0)
image_group.append(background)

# Define the foundational thermal image element layers; image_group[1:64]
#   image_group[#]=(row * 8) + column
for row in range(0, 8):
    for col in range(0, 8):
        pos_x, pos_y = element_grid(col, row)
        element = Rect(x=pos_x, y=pos_y,
                       width=ELEMENT_SIZE, height=ELEMENT_SIZE,
                       fill=None, outline=None, stroke=0)
        image_group.append(element)

# Define labels and values using element grid coordinates
status_label = Label(font, text="", color=BLACK, max_glyphs=6)
pos_x, pos_y = element_grid(2.5, 4)
status_label.x = pos_x
status_label.y = pos_y
image_group.append(status_label)  # image_group[65]

alarm_label = Label(font, text="alm", color=WHITE, max_glyphs=3)
pos_x, pos_y = element_grid(-1.8, 1.5)
alarm_label.x = pos_x
alarm_label.y = pos_y
image_group.append(alarm_label)  # image_group[66]

max_label = Label(font, text="max", color=RED, max_glyphs=3)
pos_x, pos_y = element_grid(-1.8, 3.5)
max_label.x = pos_x
max_label.y = pos_y
image_group.append(max_label)  # image_group[67]

min_label = Label(font, text="min", color=CYAN, max_glyphs=3)
pos_x, pos_y = element_grid(-1.8, 7.5)
min_label.x = pos_x
min_label.y = pos_y
image_group.append(min_label)  # image_group[68]

ave_label = Label(font, text="ave", color=YELLOW, max_glyphs=3)
pos_x, pos_y = element_grid(-1.8, 5.5)
ave_label.x = pos_x
ave_label.y = pos_y
image_group.append(ave_label)  # image_group[69]

alarm_value = Label(font, text=str(ALARM_F), color=WHITE, max_glyphs=5)
pos_x, pos_y = element_grid(-1.8, 0.5)
alarm_value.x = pos_x
alarm_value.y = pos_y
image_group.append(alarm_value)  # image_group[70]

max_value = Label(font, text=str(MAX_RANGE_F), color=RED, max_glyphs=5)
pos_x, pos_y = element_grid(-1.8, 2.5)
max_value.x = pos_x
max_value.y = pos_y
image_group.append(max_value)  # image_group[71]

min_value = Label(font, text=str(MIN_RANGE_F), color=CYAN, max_glyphs=5)
pos_x, pos_y = element_grid(-1.8, 6.5)
min_value.x = pos_x
min_value.y = pos_y
image_group.append(min_value)  # image_group[72]

ave_value = Label(font, text="---", color=YELLOW, max_glyphs=5)
pos_x, pos_y = element_grid(-1.8, 4.5)
ave_value.x = pos_x
ave_value.y = pos_y
image_group.append(ave_value)  # image_group[73]

min_histo = Label(font, text="", color=CYAN, max_glyphs=3)
pos_x, pos_y = element_grid(0.5, 7.5)
min_histo.x = pos_x
min_histo.y = pos_y
image_group.append(min_histo)  # image_group[74]

max_histo = Label(font, text="", color=RED, max_glyphs=3)
pos_x, pos_y = element_grid(6.5, 7.5)
max_histo.x = pos_x
max_histo.y = pos_y
image_group.append(max_histo)  # image_group[75]

range_histo = Label(font, text="", color=BLUE, max_glyphs=7)
pos_x, pos_y = element_grid(2.5, 7.5)
range_histo.x = pos_x
range_histo.y = pos_y
image_group.append(range_histo)  # image_group[76]

###--- PRIMARY PROCESS SETUP ---###
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   # There are no initial range values
orig_min_range_f = 0

# Activate display and play welcome tone
board.DISPLAY.show(image_group)
panel.play_tone(880, 0.1)  # A5; ready to start looking

###--- PRIMARY PROCESS LOOP ---###
while True:
    if display_hold:  # Flash hold status text label
        flash_status("-HOLD-")
    else:
        image = amg8833.pixels  # Get camera data list if not in hold mode
        status_label.text = ""  # Clear hold mode status text label

    if display_image:  # Image display mode and gather min, max, and sum stats
        v_min, v_max, v_sum = update_image_frame()
    else:  # Histogram display mode and gather min, max, and sum stats
        v_min, v_max, v_sum = update_histo_frame()

    # Display alarm setting and maxumum, minimum, and average stats
    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_sum // 64))

    # Flash first NeoPixel and play alarm notes if alarm threshold is exceeded
    # Second alarm note frequency is proportional to value above threshold
    if v_max >= ALARM_C:
        panel.pixels.fill(RED)
        panel.play_tone(880, 0.015)  # A5
        panel.play_tone(880 + (10 * (v_max - ALARM_C)), 0.015)  # A5
        panel.pixels.fill(BLACK)

    # See if a panel button is pressed
    if panel.button.a:  # Toggle display hold (shutter = button A)
        panel.play_tone(1319, 0.030)  # E6
        while panel.button.a:
            pass   # wait for button release
        if not display_hold:
            display_hold = True
        else:
            display_hold = False

    if panel.button.b:  # Toggle image/histogram mode (display mode = button B)
        panel.play_tone(659, 0.030)  # E5
        while panel.button.b:
            pass  # wait for button release
        if display_image:
            display_image = False
        else:
            display_image = True

    if panel.button.select:  # toggle focus mode (focus mode = select button)
        panel.play_tone(698, 0.030)  # F5
        if display_focus:
            display_focus = False  # restore previous (original) range values
            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)
        else:
            display_focus = True  # set range values to image min/max
            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)
            MIN_RANGE_C = v_min  # update range temp in Celsius
            MAX_RANGE_C = v_max  # update range temp in Celsius
            flash_status("FOCUS", 0.2)
        while panel.button.select:
            pass  # wait for button release

    if panel.button.start:  # activate setup mode (setup mode = start button)
        panel.play_tone(784, 0.030)  # G5
        while panel.button.start:
            pass  # wait for button release

        # 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)

    # bottom of primary loop

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.

Download: file
# 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))

Finally, the power-up alarm and temperature display range settings are contained in the thermal_cam_config.py file. All values are in degrees Fahrenheit.

Download: file
# thermal_cam_config.py
### Alarm and range default values in Farenheit ###
ALARM_F     = 120
MIN_RANGE_F =  60
MAX_RANGE_F = 120

After copying all the project files to the PyGamer, you'll see the camera's splash graphics, hear a couple of beeps, and the thermal image will appear.

The next section shows the features of the camera and how it operates.

This guide was first published on Jan 29, 2020. It was last updated on Jan 29, 2020.
This page (CircuitPython Code) was last updated on Aug 12, 2020.