code.py
  1  # SPDX-FileCopyrightText: 2022 Jan Goolsbey for Adafruit Industries
  2  # SPDX-License-Identifier: MIT
  3  
  4  """
  5  `thermalcamera`
  6  ================================================================================
  7  PyGamer/PyBadge Thermal Camera Project
  8  """
  9  
 10  import time
 11  import gc
 12  import board
 13  import keypad
 14  import busio
 15  from ulab import numpy as np
 16  import displayio
 17  import neopixel
 18  from analogio import AnalogIn
 19  from digitalio import DigitalInOut
 20  from simpleio import map_range, tone
 21  from adafruit_display_text.label import Label
 22  from adafruit_bitmap_font import bitmap_font
 23  from adafruit_display_shapes.rect import Rect
 24  import adafruit_amg88xx
 25  from index_to_rgb.iron import index_to_rgb
 26  from thermalcamera_converters import celsius_to_fahrenheit, fahrenheit_to_celsius
 27  from thermalcamera_config import ALARM_F, MIN_RANGE_F, MAX_RANGE_F, SELFIE
 28  
 29  
 30  # Instantiate the integral display and define its size
 31  display = board.DISPLAY
 32  display.brightness = 1.0
 33  WIDTH = display.width
 34  HEIGHT = display.height
 35  
 36  # Load the text font from the fonts folder
 37  font_0 = bitmap_font.load_font("/fonts/OpenSans-9.bdf")
 38  
 39  # Instantiate the joystick if available
 40  if hasattr(board, "JOYSTICK_X"):
 41      # PyGamer with joystick
 42      HAS_JOYSTICK = True
 43      joystick_x = AnalogIn(board.JOYSTICK_X)
 44      joystick_y = AnalogIn(board.JOYSTICK_Y)
 45  else:
 46      # PyBadge with buttons
 47      HAS_JOYSTICK = False  # PyBadge with buttons
 48  
 49  # Enable the speaker
 50  DigitalInOut(board.SPEAKER_ENABLE).switch_to_output(value=True)
 51  
 52  # Instantiate and clear the NeoPixels
 53  pixels = neopixel.NeoPixel(board.NEOPIXEL, 5, pixel_order=neopixel.GRB)
 54  pixels.brightness = 0.25
 55  pixels.fill(0x000000)
 56  
 57  # Initialize ShiftRegisterKeys to read PyGamer/PyBadge buttons
 58  panel = keypad.ShiftRegisterKeys(
 59      clock=board.BUTTON_CLOCK,
 60      data=board.BUTTON_OUT,
 61      latch=board.BUTTON_LATCH,
 62      key_count=8,
 63      value_when_pressed=True,
 64  )
 65  
 66  # Define front panel button event values
 67  BUTTON_LEFT = 7  # LEFT button
 68  BUTTON_UP = 6  # UP button
 69  BUTTON_DOWN = 5  # DOWN button
 70  BUTTON_RIGHT = 4  # RIGHT button
 71  BUTTON_FOCUS = 3  # SELECT button
 72  BUTTON_SET = 2  # START button
 73  BUTTON_HOLD = 1  # button A
 74  BUTTON_IMAGE = 0  # button B
 75  
 76  # Initiate the AMG8833 Thermal Camera
 77  i2c = busio.I2C(board.SCL, board.SDA, frequency=400000)
 78  amg8833 = adafruit_amg88xx.AMG88XX(i2c)
 79  
 80  # Display splash graphics
 81  splash = displayio.Group(scale=display.width // 160)
 82  bitmap = displayio.OnDiskBitmap("/thermalcamera_splash.bmp")
 83  splash.append(displayio.TileGrid(bitmap, pixel_shader=bitmap.pixel_shader))
 84  board.DISPLAY.show(splash)
 85  
 86  # Thermal sensor grid axis size; AMG8833 sensor is 8x8
 87  SENSOR_AXIS = 8
 88  
 89  # Display grid parameters
 90  GRID_AXIS = (2 * SENSOR_AXIS) - 1  # Number of cells per axis
 91  GRID_SIZE = HEIGHT  # Axis size (pixels) for a square grid
 92  GRID_X_OFFSET = WIDTH - GRID_SIZE  # Right-align grid with display boundary
 93  CELL_SIZE = GRID_SIZE // GRID_AXIS  # Size of a grid cell in pixels
 94  PALETTE_SIZE = 100  # Number of display colors in spectral palette (must be > 0)
 95  
 96  # Set up the 2-D sensor data narray
 97  SENSOR_DATA = np.array(range(SENSOR_AXIS**2)).reshape((SENSOR_AXIS, SENSOR_AXIS))
 98  # Set up and load the 2-D display color index narray with a spectrum
 99  GRID_DATA = np.array(range(GRID_AXIS**2)).reshape((GRID_AXIS, GRID_AXIS)) / (
100      GRID_AXIS**2
101  )
102  # Set up the histogram accumulation narray
103  # HISTOGRAM = np.zeros(GRID_AXIS)
104  
105  # Convert default alarm and min/max range values from config file
106  ALARM_C = fahrenheit_to_celsius(ALARM_F)
107  MIN_RANGE_C = fahrenheit_to_celsius(MIN_RANGE_F)
108  MAX_RANGE_C = fahrenheit_to_celsius(MAX_RANGE_F)
109  
110  # Default colors for temperature value sidebar
111  BLACK = 0x000000
112  RED = 0xFF0000
113  YELLOW = 0xFFFF00
114  CYAN = 0x00FFFF
115  BLUE = 0x0000FF
116  WHITE = 0xFFFFFF
117  
118  # Text colors for setup helper's on-screen parameters
119  SETUP_COLORS = [("ALARM", WHITE), ("RANGE", RED), ("RANGE", CYAN)]
120  
121  # ### Helpers ###
122  def play_tone(freq=440, duration=0.01):
123      """Play a tone over the speaker"""
124      tone(board.A0, freq, duration)
125  
126  
127  def flash_status(text="", duration=0.05):
128      """Flash status message once"""
129      status_label.color = WHITE
130      status_label.text = text
131      time.sleep(duration)
132      status_label.color = BLACK
133      time.sleep(duration)
134      status_label.text = ""
135  
136  
137  def update_image_frame(selfie=False):
138      """Get camera data and update display"""
139      for _row in range(0, GRID_AXIS):
140          for _col in range(0, GRID_AXIS):
141              if selfie:
142                  color_index = GRID_DATA[GRID_AXIS - 1 - _row][_col]
143              else:
144                  color_index = GRID_DATA[GRID_AXIS - 1 - _row][GRID_AXIS - 1 - _col]
145              color = index_to_rgb(round(color_index * PALETTE_SIZE, 0) / PALETTE_SIZE)
146              if color != image_group[((_row * GRID_AXIS) + _col)].fill:
147                  image_group[((_row * GRID_AXIS) + _col)].fill = color
148  
149  
150  def update_histo_frame():
151      """Calculate and display histogram"""
152      min_histo.text = str(MIN_RANGE_F)  # Display the legend
153      max_histo.text = str(MAX_RANGE_F)
154  
155      histogram = np.zeros(GRID_AXIS)  # Clear histogram accumulation array
156      # Collect camera data and calculate the histogram
157      for _row in range(0, GRID_AXIS):
158          for _col in range(0, GRID_AXIS):
159              histo_index = int(map_range(GRID_DATA[_col, _row], 0, 1, 0, GRID_AXIS - 1))
160              histogram[histo_index] = histogram[histo_index] + 1
161  
162      histo_scale = np.max(histogram) / (GRID_AXIS - 1)
163      if histo_scale <= 0:
164          histo_scale = 1
165  
166      # Display the histogram
167      for _col in range(0, GRID_AXIS):
168          for _row in range(0, GRID_AXIS):
169              if histogram[_col] / histo_scale > GRID_AXIS - 1 - _row:
170                  image_group[((_row * GRID_AXIS) + _col)].fill = index_to_rgb(
171                      round((_col / GRID_AXIS), 3)
172                  )
173              else:
174                  image_group[((_row * GRID_AXIS) + _col)].fill = BLACK
175  
176  
177  def ulab_bilinear_interpolation():
178      """2x bilinear interpolation to upscale the sensor data array; by @v923z
179      and @David.Glaude."""
180      GRID_DATA[1::2, ::2] = SENSOR_DATA[:-1, :]
181      GRID_DATA[1::2, ::2] += SENSOR_DATA[1:, :]
182      GRID_DATA[1::2, ::2] /= 2
183      GRID_DATA[::, 1::2] = GRID_DATA[::, :-1:2]
184      GRID_DATA[::, 1::2] += GRID_DATA[::, 2::2]
185      GRID_DATA[::, 1::2] /= 2
186  
187  
188  # pylint: disable=too-many-branches
189  # pylint: disable=too-many-statements
190  def setup_mode():
191      """Change alarm threshold and minimum/maximum range values"""
192      status_label.color = WHITE
193      status_label.text = "-SET-"
194  
195      ave_label.color = BLACK  # Turn off average label and value display
196      ave_value.color = BLACK
197  
198      max_value.text = str(MAX_RANGE_F)  # Display maximum range value
199      min_value.text = str(MIN_RANGE_F)  # Display minimum range value
200  
201      time.sleep(0.8)  # Show SET status text before setting parameters
202      status_label.text = ""  # Clear status text
203  
204      param_index = 0  # Reset index of parameter to set
205  
206      setup_state = "SETUP"  # Set initial state
207      while setup_state == "SETUP":
208          # Select parameter to set
209          setup_state = "SELECT_PARAM"  # Parameter selection state
210          while setup_state == "SELECT_PARAM":
211              param_index = max(0, min(2, param_index))
212              status_label.text = SETUP_COLORS[param_index][0]
213              image_group[param_index + 226].color = BLACK
214              status_label.color = BLACK
215              time.sleep(0.25)
216              image_group[param_index + 226].color = SETUP_COLORS[param_index][1]
217              status_label.color = WHITE
218              time.sleep(0.25)
219  
220              param_index -= get_joystick()
221  
222              _buttons = panel.events.get()
223              if _buttons and _buttons.pressed:
224                  if _buttons.key_number == BUTTON_UP:  # HOLD button pressed
225                      param_index = param_index - 1
226                  if _buttons.key_number == BUTTON_DOWN:  # SET button pressed
227                      param_index = param_index + 1
228                  if _buttons.key_number == BUTTON_HOLD:  # HOLD button pressed
229                      play_tone(1319, 0.030)  # Musical note E6
230                      setup_state = "ADJUST_VALUE"  # Next state
231                  if _buttons.key_number == BUTTON_SET:  # SET button pressed
232                      play_tone(1319, 0.030)  # Musical note E6
233                      setup_state = "EXIT"  # Next state
234  
235          # Adjust parameter value
236          param_value = int(image_group[param_index + 230].text)
237  
238          while setup_state == "ADJUST_VALUE":
239              param_value = max(32, min(157, param_value))
240              image_group[param_index + 230].text = str(param_value)
241              image_group[param_index + 230].color = BLACK
242              status_label.color = BLACK
243              time.sleep(0.05)
244              image_group[param_index + 230].color = SETUP_COLORS[param_index][1]
245              status_label.color = WHITE
246              time.sleep(0.2)
247  
248              param_value += get_joystick()
249  
250              _buttons = panel.events.get()
251              if _buttons and _buttons.pressed:
252                  if _buttons.key_number == BUTTON_UP:  # HOLD button pressed
253                      param_value = param_value + 1
254                  if _buttons.key_number == BUTTON_DOWN:  # SET button pressed
255                      param_value = param_value - 1
256                  if _buttons.key_number == BUTTON_HOLD:  # HOLD button pressed
257                      play_tone(1319, 0.030)  # Musical note E6
258                      setup_state = "SETUP"  # Next state
259                  if _buttons.key_number == BUTTON_SET:  # SET button pressed
260                      play_tone(1319, 0.030)  # Musical note E6
261                      setup_state = "EXIT"  # Next state
262  
263      # Exit setup process
264      status_label.text = "RESUME"
265      time.sleep(0.5)
266      status_label.text = ""
267  
268      # Display average label and value
269      ave_label.color = YELLOW
270      ave_value.color = YELLOW
271      return int(alarm_value.text), int(max_value.text), int(min_value.text)
272  
273  
274  def get_joystick():
275      """Read the joystick and interpret as up/down buttons (PyGamer)"""
276      if HAS_JOYSTICK:
277          if joystick_y.value < 20000:
278              # Up
279              return 1
280          if joystick_y.value > 44000:
281              # Down
282              return -1
283      return 0
284  
285  
286  play_tone(440, 0.1)  # Musical note A4
287  play_tone(880, 0.1)  # Musical note A5
288  
289  # ### Define the display group ###
290  mkr_t0 = time.monotonic()  # Time marker: Define Display Elements
291  image_group = displayio.Group(scale=1)
292  
293  # Define the foundational thermal image grid cells; image_group[0:224]
294  #   image_group[#] = image_group[ (row * GRID_AXIS) + column ]
295  for row in range(0, GRID_AXIS):
296      for col in range(0, GRID_AXIS):
297          cell_x = (col * CELL_SIZE) + GRID_X_OFFSET
298          cell_y = row * CELL_SIZE
299          cell = Rect(
300              x=cell_x,
301              y=cell_y,
302              width=CELL_SIZE,
303              height=CELL_SIZE,
304              fill=None,
305              outline=None,
306              stroke=0,
307          )
308          image_group.append(cell)
309  
310  # Define labels and values
311  status_label = Label(font_0, text="", color=None)
312  status_label.anchor_point = (0.5, 0.5)
313  status_label.anchored_position = ((WIDTH // 2) + (GRID_X_OFFSET // 2), HEIGHT // 2)
314  image_group.append(status_label)  # image_group[225]
315  
316  alarm_label = Label(font_0, text="alm", color=WHITE)
317  alarm_label.anchor_point = (0, 0)
318  alarm_label.anchored_position = (1, 16)
319  image_group.append(alarm_label)  # image_group[226]
320  
321  max_label = Label(font_0, text="max", color=RED)
322  max_label.anchor_point = (0, 0)
323  max_label.anchored_position = (1, 46)
324  image_group.append(max_label)  # image_group[227]
325  
326  min_label = Label(font_0, text="min", color=CYAN)
327  min_label.anchor_point = (0, 0)
328  min_label.anchored_position = (1, 106)
329  image_group.append(min_label)  # image_group[228]
330  
331  ave_label = Label(font_0, text="ave", color=YELLOW)
332  ave_label.anchor_point = (0, 0)
333  ave_label.anchored_position = (1, 76)
334  image_group.append(ave_label)  # image_group[229]
335  
336  alarm_value = Label(font_0, text=str(ALARM_F), color=WHITE)
337  alarm_value.anchor_point = (0, 0)
338  alarm_value.anchored_position = (1, 5)
339  image_group.append(alarm_value)  # image_group[230]
340  
341  max_value = Label(font_0, text=str(MAX_RANGE_F), color=RED)
342  max_value.anchor_point = (0, 0)
343  max_value.anchored_position = (1, 35)
344  image_group.append(max_value)  # image_group[231]
345  
346  min_value = Label(font_0, text=str(MIN_RANGE_F), color=CYAN)
347  min_value.anchor_point = (0, 0)
348  min_value.anchored_position = (1, 95)
349  image_group.append(min_value)  # image_group[232]
350  
351  ave_value = Label(font_0, text="---", color=YELLOW)
352  ave_value.anchor_point = (0, 0)
353  ave_value.anchored_position = (1, 65)
354  image_group.append(ave_value)  # image_group[233]
355  
356  min_histo = Label(font_0, text="", color=None)
357  min_histo.anchor_point = (0, 0.5)
358  min_histo.anchored_position = (GRID_X_OFFSET, 121)
359  image_group.append(min_histo)  # image_group[234]
360  
361  max_histo = Label(font_0, text="", color=None)
362  max_histo.anchor_point = (1, 0.5)
363  max_histo.anchored_position = (WIDTH - 2, 121)
364  image_group.append(max_histo)  # image_group[235]
365  
366  range_histo = Label(font_0, text="-RANGE-", color=None)
367  range_histo.anchor_point = (0.5, 0.5)
368  range_histo.anchored_position = ((WIDTH // 2) + (GRID_X_OFFSET // 2), 121)
369  image_group.append(range_histo)  # image_group[236]
370  
371  # ###--- PRIMARY PROCESS SETUP ---###
372  mkr_t1 = time.monotonic()  # Time marker: Primary Process Setup
373  # pylint: disable=no-member
374  mem_fm1 = gc.mem_free()  # Monitor free memory
375  DISPLAY_IMAGE = True  # Image display mode; False for histogram
376  DISPLAY_HOLD = False  # Active display mode; True to hold display
377  DISPLAY_FOCUS = False  # Standard display range; True to focus display range
378  
379  # pylint: disable=invalid-name
380  orig_max_range_f = 0  # Establish temporary range variables
381  orig_min_range_f = 0
382  
383  # Activate display, show preloaded sample spectrum, and play welcome tone
384  display.show(image_group)
385  update_image_frame()
386  flash_status("IRON", 0.75)
387  play_tone(880, 0.010)  # Musical note A5
388  
389  # ###--- PRIMARY PROCESS LOOP ---###
390  while True:
391      mkr_t2 = time.monotonic()  # Time marker: Acquire Sensor Data
392      if DISPLAY_HOLD:
393          flash_status("-HOLD-", 0.25)
394      else:
395          sensor = amg8833.pixels  # Get sensor_data data
396      # Put sensor data in array; limit to the range of 0, 80
397      SENSOR_DATA = np.clip(np.array(sensor), 0, 80)
398  
399      # Update and display alarm setting and max, min, and ave stats
400      mkr_t4 = time.monotonic()  # Time marker: Display Statistics
401      v_max = np.max(SENSOR_DATA)
402      v_min = np.min(SENSOR_DATA)
403      v_ave = np.mean(SENSOR_DATA)
404  
405      alarm_value.text = str(ALARM_F)
406      max_value.text = str(celsius_to_fahrenheit(v_max))
407      min_value.text = str(celsius_to_fahrenheit(v_min))
408      ave_value.text = str(celsius_to_fahrenheit(v_ave))
409  
410      # Normalize temperature to index values and interpolate
411      mkr_t5 = time.monotonic()  # Time marker: Normalize and Interpolate
412      SENSOR_DATA = (SENSOR_DATA - MIN_RANGE_C) / (MAX_RANGE_C - MIN_RANGE_C)
413      GRID_DATA[::2, ::2] = SENSOR_DATA  # Copy sensor data to the grid array
414      ulab_bilinear_interpolation()  # Interpolate to produce 15x15 result
415  
416      # Display image or histogram
417      mkr_t6 = time.monotonic()  # Time marker: Display Image
418      if DISPLAY_IMAGE:
419          update_image_frame(selfie=SELFIE)
420      else:
421          update_histo_frame()
422  
423      # If alarm threshold is reached, flash NeoPixels and play alarm tone
424      if v_max >= ALARM_C:
425          pixels.fill(RED)
426          play_tone(880, 0.015)  # Musical note A5
427          pixels.fill(BLACK)
428  
429      # See if a panel button is pressed
430      buttons = panel.events.get()
431      if buttons and buttons.pressed:
432          if buttons.key_number == BUTTON_HOLD:
433              # Toggle display hold (shutter)
434              play_tone(1319, 0.030)  # Musical note E6
435              DISPLAY_HOLD = not DISPLAY_HOLD
436  
437          if buttons.key_number == BUTTON_IMAGE:
438              # Toggle image/histogram mode (display image)
439              play_tone(659, 0.030)  # Musical note E5
440              DISPLAY_IMAGE = not DISPLAY_IMAGE
441  
442              if DISPLAY_IMAGE:
443                  min_histo.color = None
444                  max_histo.color = None
445                  range_histo.color = None
446              else:
447                  min_histo.color = CYAN
448                  max_histo.color = RED
449                  range_histo.color = BLUE
450  
451          if buttons.key_number == BUTTON_FOCUS:  # Toggle display focus mode
452              play_tone(698, 0.030)  # Musical note F5
453              DISPLAY_FOCUS = not DISPLAY_FOCUS
454              if DISPLAY_FOCUS:
455                  # Set range values to image min/max for focused image display
456                  orig_min_range_f = MIN_RANGE_F
457                  orig_max_range_f = MAX_RANGE_F
458                  MIN_RANGE_F = celsius_to_fahrenheit(v_min)
459                  MAX_RANGE_F = celsius_to_fahrenheit(v_max)
460                  # Update range min and max values in Celsius
461                  MIN_RANGE_C = v_min
462                  MAX_RANGE_C = v_max
463                  flash_status("FOCUS", 0.2)
464              else:
465                  # Restore previous (original) range values for image display
466                  MIN_RANGE_F = orig_min_range_f
467                  MAX_RANGE_F = orig_max_range_f
468                  # Update range min and max values in Celsius
469                  MIN_RANGE_C = fahrenheit_to_celsius(MIN_RANGE_F)
470                  MAX_RANGE_C = fahrenheit_to_celsius(MAX_RANGE_F)
471                  flash_status("ORIG", 0.2)
472  
473          if buttons.key_number == BUTTON_SET:
474              # Activate setup mode
475              play_tone(784, 0.030)  # Musical note G5
476  
477              # Invoke startup helper; update alarm and range values
478              ALARM_F, MAX_RANGE_F, MIN_RANGE_F = setup_mode()
479              ALARM_C = fahrenheit_to_celsius(ALARM_F)
480              MIN_RANGE_C = fahrenheit_to_celsius(MIN_RANGE_F)
481              MAX_RANGE_C = fahrenheit_to_celsius(MAX_RANGE_F)
482  
483      mkr_t7 = time.monotonic()  # Time marker: End of Primary Process
484      gc.collect()
485      mem_fm7 = gc.mem_free()
486  
487      # Print frame performance report
488      print("*** PyBadge/Gamer Performance Stats ***")
489      print(f"  define display: {(mkr_t1 - mkr_t0):6.3f} sec")
490      print(f"  free memory:    {mem_fm1 / 1000:6.3f} Kb")
491      print("")
492      print("                          rate")
493      print(f" 1) acquire: {(mkr_t4 - mkr_t2):6.3f} sec  ", end="")
494      print(f"{(1 / (mkr_t4 - mkr_t2)):5.1f}  /sec")
495      print(f" 2) stats:   {(mkr_t5 - mkr_t4):6.3f} sec")
496      print(f" 3) convert: {(mkr_t6 - mkr_t5):6.3f} sec")
497      print(f" 4) display: {(mkr_t7 - mkr_t6):6.3f} sec")
498      print("             =======")
499      print(f"total frame: {(mkr_t7 - mkr_t2):6.3f} sec  ", end="")
500      print(f"{(1 / (mkr_t7 - mkr_t2)):5.1f}   /sec")
501      print(f"           free memory:   {mem_fm7 / 1000:6.3f} Kb")
502      print("")