/ PyGamer_Improved_Thermal_Camera / code.py
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("")