commit deb475ff78601b7c641a25d3e7f5abc9528891fd Author: Jens Ahrensfeld Date: Tue May 27 18:44:40 2025 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f11b75 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/p2pro_cam.py b/p2pro_cam.py new file mode 100644 index 0000000..49a16aa --- /dev/null +++ b/p2pro_cam.py @@ -0,0 +1,363 @@ +#!/usr/bin/env python3 +''' +Les Wright 21 June 2023 +https://youtube.com/leslaboratory +A Python program to read, parse and display thermal data from the Topdon TC001 Thermal camera! +''' + +import cv2 +import numpy as np +import argparse +import time +import io + +def main(): + # 256x192 General settings + width = 256 # Sensor width + height = 192 # sensor height + scale = 3 # scale multiplier + newWidth = width * scale + newHeight = height * scale + alpha = 1.0 # Contrast control (1.0-3.0) + colormap = -1 + font = cv2.FONT_HERSHEY_SIMPLEX + dispFullscreen = False + cv2.namedWindow('Thermal', cv2.WINDOW_GUI_NORMAL) + cv2.resizeWindow('Thermal', newWidth, newHeight) + rad = 0 # blur radius + threshold = 2 + hud = True + recording = False + elapsed = "00:00:00" + snaptime = "None" + + # We need to know if we are running on the Pi, because openCV behaves a little oddly on all the builds! + # https://raspberrypi.stackexchange.com/questions/5100/detect-that-a-python-program-is-running-on-the-pi + def is_raspberrypi(): + try: + with io.open('/sys/firmware/devicetree/base/model', 'r') as m: + if 'raspberry pi' in m.read().lower(): return True + except Exception: + pass + return False + + + isPi = is_raspberrypi() + + parser = argparse.ArgumentParser() + parser.add_argument("--device", type=int, default=0, help="Video Device number e.g. 0, use v4l2-ctl --list-devices") + args = parser.parse_args() + + if args.device: + dev = args.device + else: + dev = 0 + + # init video + cap = cv2.VideoCapture('/dev/video' + str(dev), cv2.CAP_V4L) + # cap = cv2.VideoCapture(0) + # pull in the video but do NOT automatically convert to RGB, else it breaks the temperature data! + # https://stackoverflow.com/questions/63108721/opencv-setting-videocap-property-to-cap-prop-convert-rgb-generates-weird-boolean + if isPi == True: + cap.set(cv2.CAP_PROP_CONVERT_RGB, 0.0) + else: + cap.set(cv2.CAP_PROP_CONVERT_RGB, 0.0) + + def rec(): + now = time.strftime("%Y%m%d--%H%M%S") + # do NOT use mp4 here, it is flakey! + videoOut = cv2.VideoWriter(now + 'output.avi', cv2.VideoWriter_fourcc(*'XVID'), 25, (newWidth, newHeight)) + return (videoOut) + + + def snapshot(heatmap): + # I would put colons in here, but it Win throws a fit if you try and open them! + now = time.strftime("%Y%m%d-%H%M%S") + snaptime = time.strftime("%H:%M:%S") + cv2.imwrite("TC001" + now + ".png", heatmap) + return snaptime + + P2Pro_resolution = (256, 384) + + while (cap.isOpened()): + # Capture frame-by-frame + ret, frame = cap.read() + if ret == True: + frame = frame[0] + + # split video frame (top is pseudo color, bottom is temperature data) + frame_mid_pos = int(len(frame) / 2) + picture_data = frame[0:frame_mid_pos] + thermal_data = frame[frame_mid_pos:] + + # convert buffers to numpy arrays + yuv_picture = np.frombuffer(picture_data, dtype=np.uint8).reshape( + (height, width, 2)) + rgb_picture = cv2.cvtColor(yuv_picture, cv2.COLOR_YUV2RGB_YUY2) + thermal_picture_16 = np.frombuffer(thermal_data, dtype=np.uint16).reshape( + (height, width)) + + thermal_picture_8 = np.frombuffer(thermal_data, dtype=np.uint8).reshape( + (height, width, 2)) + + imdata = yuv_picture + thdata = thermal_picture_16 + + + # imdata, thdata = np.array_split(frame, 2) + # now parse the data from the bottom frame and convert to temp! + # https://www.eevblog.com/forum/thermal-imaging/infiray-and-their-p2-pro-discussion/200/ + # Huge props to LeoDJ for figuring out how the data is stored and how to compute temp from it. + # grab data from the center pixel... + rawtemp = thermal_picture_16[96][128] + temp = (rawtemp / 64) - 273.15 + temp = round(temp, 2) + + # find the max temperature in the frame + maxtemp = thdata[...].max() + maxtemp = (maxtemp / 64) - 273.15 + maxtemp = round(maxtemp, 2) + posmax = thdata[...].argmax() + #since argmax returns a linear index, convert back to row and col + mcol, mrow = divmod(posmax, width) + # find the lowest temperature in the frame + mintemp = thdata[...].min() + mintemp = (mintemp / 64) - 273.15 + mintemp = round(mintemp, 2) + posmin = thdata[...].argmin() + lcol, lrow = divmod(posmin, width) + + # find the average temperature in the frame + avgtemp = thdata[...].mean() + avgtemp = (avgtemp / 64) - 273.15 + avgtemp = round(avgtemp, 2) + + # Convert the real image to RGB + bgr = cv2.cvtColor(imdata, cv2.COLOR_YUV2BGR_YUYV) + # Contrast + bgr = cv2.convertScaleAbs(bgr, alpha=alpha) # Contrast + # bicubic interpolate, upscale and blur + bgr = cv2.resize(bgr, (newWidth, newHeight), interpolation=cv2.INTER_CUBIC) # Scale up! + if rad > 0: + bgr = cv2.blur(bgr, (rad, rad)) + + # apply colormap + if colormap == -1: + heatmap = bgr + cmapText = 'None' + if colormap == 0: + heatmap = cv2.applyColorMap(bgr, cv2.COLORMAP_JET) + cmapText = 'Jet' + if colormap == 1: + heatmap = cv2.applyColorMap(bgr, cv2.COLORMAP_HOT) + cmapText = 'Hot' + if colormap == 2: + heatmap = cv2.applyColorMap(bgr, cv2.COLORMAP_MAGMA) + cmapText = 'Magma' + if colormap == 3: + heatmap = cv2.applyColorMap(bgr, cv2.COLORMAP_INFERNO) + cmapText = 'Inferno' + if colormap == 4: + heatmap = cv2.applyColorMap(bgr, cv2.COLORMAP_PLASMA) + cmapText = 'Plasma' + if colormap == 5: + heatmap = cv2.applyColorMap(bgr, cv2.COLORMAP_BONE) + cmapText = 'Bone' + if colormap == 6: + heatmap = cv2.applyColorMap(bgr, cv2.COLORMAP_SPRING) + cmapText = 'Spring' + if colormap == 7: + heatmap = cv2.applyColorMap(bgr, cv2.COLORMAP_AUTUMN) + cmapText = 'Autumn' + if colormap == 8: + heatmap = cv2.applyColorMap(bgr, cv2.COLORMAP_VIRIDIS) + cmapText = 'Viridis' + if colormap == 9: + heatmap = cv2.applyColorMap(bgr, cv2.COLORMAP_PARULA) + cmapText = 'Parula' + if colormap == 10: + heatmap = cv2.applyColorMap(bgr, cv2.COLORMAP_RAINBOW) + heatmap = cv2.cvtColor(heatmap, cv2.COLOR_BGR2RGB) + cmapText = 'Inv Rainbow' + + # print(heatmap.shape) + + # draw crosshairs + cv2.line(heatmap, (int(newWidth / 2), int(newHeight / 2) + 20), \ + (int(newWidth / 2), int(newHeight / 2) - 20), (255, 255, 255), 2) # vline + cv2.line(heatmap, (int(newWidth / 2) + 20, int(newHeight / 2)), \ + (int(newWidth / 2) - 20, int(newHeight / 2)), (255, 255, 255), 2) # hline + + cv2.line(heatmap, (int(newWidth / 2), int(newHeight / 2) + 20), \ + (int(newWidth / 2), int(newHeight / 2) - 20), (0, 0, 0), 1) # vline + cv2.line(heatmap, (int(newWidth / 2) + 20, int(newHeight / 2)), \ + (int(newWidth / 2) - 20, int(newHeight / 2)), (0, 0, 0), 1) # hline + # show temp + cv2.putText(heatmap, str(temp) + ' C', (int(newWidth / 2) + 10, int(newHeight / 2) - 10), \ + cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 0, 0), 2, cv2.LINE_AA) + cv2.putText(heatmap, str(temp) + ' C', (int(newWidth / 2) + 10, int(newHeight / 2) - 10), \ + cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 255, 255), 1, cv2.LINE_AA) + + offset = 0 + if hud == True: + # display black box for our data + cv2.rectangle(heatmap, (0, 0), (160, 120), (0, 0, 0), -1) + # put text in the box + cv2.putText(heatmap, 'Avg Temp: ' + str(avgtemp) + ' C', (10, 14+offset), \ + cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 255, 255), 1, cv2.LINE_AA) + + cv2.putText(heatmap, 'Label Threshold: ' + str(threshold) + ' C', (10, 28+offset), \ + cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 255, 255), 1, cv2.LINE_AA) + + cv2.putText(heatmap, 'Colormap: ' + cmapText, (10, 42+offset), \ + cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 255, 255), 1, cv2.LINE_AA) + + cv2.putText(heatmap, 'Blur: ' + str(rad) + ' ', (10, 56+offset), \ + cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 255, 255), 1, cv2.LINE_AA) + + cv2.putText(heatmap, 'Scaling: ' + str(scale) + ' ', (10, 70+offset), \ + cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 255, 255), 1, cv2.LINE_AA) + + cv2.putText(heatmap, 'Contrast: ' + str(alpha) + ' ', (10, 84+offset), \ + cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 255, 255), 1, cv2.LINE_AA) + + cv2.putText(heatmap, 'Snapshot: ' + snaptime + ' ', (10, 98+offset), \ + cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 255, 255), 1, cv2.LINE_AA) + + if recording == False: + cv2.putText(heatmap, 'Recording: ' + elapsed, (10, 112+offset), \ + cv2.FONT_HERSHEY_SIMPLEX, 0.4, (200, 200, 200), 1, cv2.LINE_AA) + if recording == True: + cv2.putText(heatmap, 'Recording: ' + elapsed, (10, 112+offset), \ + cv2.FONT_HERSHEY_SIMPLEX, 0.4, (40, 40, 255), 1, cv2.LINE_AA) + + # Yeah, this looks like we can probably do this next bit more efficiently! + # display floating max temp + if maxtemp > avgtemp + threshold: + cv2.circle(heatmap, (mrow * scale, mcol * scale), 5, (0, 0, 0), 2) + cv2.circle(heatmap, (mrow * scale, mcol * scale), 5, (0, 0, 255), -1) + cv2.putText(heatmap, str(maxtemp) + ' C', ((mrow * scale) + 10, (mcol * scale) + 5 + offset), \ + cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 0, 0), 2, cv2.LINE_AA) + cv2.putText(heatmap, str(maxtemp) + ' C', ((mrow * scale) + 10, (mcol * scale) + 5 + offset), \ + cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 255, 255), 1, cv2.LINE_AA) + + # display floating min temp + if mintemp < avgtemp - threshold: + cv2.circle(heatmap, (lrow * scale, lcol * scale), 5, (0, 0, 0), 2) + cv2.circle(heatmap, (lrow * scale, lcol * scale), 5, (255, 0, 0), -1) + cv2.putText(heatmap, str(mintemp) + ' C', ((lrow * scale) + 10, (lcol * scale) + 5 + offset), \ + cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 0, 0), 2, cv2.LINE_AA) + cv2.putText(heatmap, str(mintemp) + ' C', ((lrow * scale) + 10, (lcol * scale) + 5 + offset), \ + cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 255, 255), 1, cv2.LINE_AA) + + # display image + cv2.imshow('Thermal', heatmap) + + if recording == True: + elapsed = (time.time() - start) + elapsed = time.strftime("%H:%M:%S", time.gmtime(elapsed)) + # print(elapsed) + videoOut.write(heatmap) + + keyPress = cv2.waitKey(1) + if keyPress == ord('a'): # Increase blur radius + rad += 1 + if keyPress == ord('z'): # Decrease blur radius + rad -= 1 + if rad <= 0: + rad = 0 + + if keyPress == ord('s'): # Increase threshold + threshold += 1 + if keyPress == ord('x'): # Decrease threashold + threshold -= 1 + if threshold <= 0: + threshold = 0 + + if keyPress == ord('d'): # Increase scale + scale += 1 + if scale >= 5: + scale = 5 + newWidth = width * scale + newHeight = height * scale + if dispFullscreen == False and isPi == False: + cv2.resizeWindow('Thermal', newWidth, newHeight) + if keyPress == ord('c'): # Decrease scale + scale -= 1 + if scale <= 1: + scale = 1 + newWidth = width * scale + newHeight = height * scale + if dispFullscreen == False and isPi == False: + cv2.resizeWindow('Thermal', newWidth, newHeight) + + if keyPress == ord('q'): # enable fullscreen + dispFullscreen = True + cv2.namedWindow('Thermal', cv2.WND_PROP_FULLSCREEN) + cv2.setWindowProperty('Thermal', cv2.WND_PROP_FULLSCREEN, cv2.WINDOW_FULLSCREEN) + if keyPress == ord('w'): # disable fullscreen + dispFullscreen = False + cv2.namedWindow('Thermal', cv2.WINDOW_GUI_NORMAL) + cv2.setWindowProperty('Thermal', cv2.WND_PROP_AUTOSIZE, cv2.WINDOW_GUI_NORMAL) + cv2.resizeWindow('Thermal', newWidth, newHeight) + + if keyPress == ord('f'): # contrast+ + alpha += 0.1 + alpha = round(alpha, 1) # fix round error + if alpha >= 3.0: + alpha = 3.0 + if keyPress == ord('v'): # contrast- + alpha -= 0.1 + alpha = round(alpha, 1) # fix round error + if alpha <= 0: + alpha = 0.0 + + if keyPress == ord('h'): + if hud == True: + hud = False + elif hud == False: + hud = True + + if keyPress == ord('m'): # m to cycle through color maps + colormap += 1 + if colormap == 11: + colormap = -1 + + if keyPress == ord('r') and recording == False: # r to start reording + videoOut = rec() + recording = True + start = time.time() + if keyPress == ord('t'): # f to finish reording + recording = False + elapsed = "00:00:00" + + if keyPress == ord('p'): # f to finish reording + snaptime = snapshot(heatmap) + + if keyPress == ord('q'): + break + capture.release() + cv2.destroyAllWindows() + +if __name__ == '__main__': + print('Les Wright 21 June 2023') + print('https://youtube.com/leslaboratory') + print('A Python program to read, parse and display thermal data from the Topdon TC001 Thermal camera!') + print('') + print('Tested on Debian all features are working correctly') + print('This will work on the Pi However a number of workarounds are implemented!') + print('Seemingly there are bugs in the compiled version of cv2 that ships with the Pi!') + print('') + print('Key Bindings:') + print('') + print('a z: Increase/Decrease Blur') + print('s x: Floating High and Low Temp Label Threshold') + print('d c: Change Interpolated scale Note: This will not change the window size on the Pi') + print('f v: Contrast') + print('q w: Fullscreen Windowed (note going back to windowed does not seem to work on the Pi!)') + print('r t: Record and Stop') + print('p : Snapshot') + print('m : Cycle through ColorMaps') + print('h : Toggle HUD') + + main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cb3738c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +numpy +opencv-python