├── LICENSE └── seekpro.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /seekpro.py: -------------------------------------------------------------------------------- 1 | # Author: Victor Couty 2 | 3 | # This code is a basic wrapper for Seek thermal Pro IR camera in Python 4 | # It only requires numpy and pyusb 5 | # To run this file and test the camera, you will also need opencv 6 | 7 | import usb.core 8 | import usb.util 9 | import numpy as np 10 | 11 | # Address enum 12 | READ_CHIP_ID = 54 # 0x36 13 | START_GET_IMAGE_TRANSFER = 83 # 0x53 14 | 15 | GET_OPERATION_MODE = 61 # 0x3D 16 | GET_IMAGE_PROCESSING_MODE = 63 # 0x3F 17 | GET_FIRMWARE_INFO = 78 # 0x4E 18 | GET_FACTORY_SETTINGS = 88 # 0x58 19 | 20 | SET_OPERATION_MODE = 60 # 0x3C 21 | SET_IMAGE_PROCESSING_MODE = 62 # 0x3E 22 | SET_FIRMWARE_INFO_FEATURES = 85 # 0x55 23 | SET_FACTORY_SETTINGS_FEATURES = 86 # 0x56 24 | 25 | WIDTH = 320 26 | HEIGHT = 240 27 | RAW_WIDTH = 342 28 | RAW_HEIGHT = 260 29 | 30 | class SeekPro(): 31 | """ 32 | Seekpro class: 33 | Can read images from the Seek Thermal pro camera 34 | Can apply a calibration from the integrated black body 35 | Can locate and remove dead pixels 36 | This class only works with the PRO version ! 37 | """ 38 | def __init__(self): 39 | self.dev = usb.core.find(idVendor=0x289d, idProduct=0x0011) 40 | if not self.dev: 41 | raise IOError('Device not found') 42 | self.dev.set_configuration() 43 | self.calib = None 44 | for i in range(5): 45 | # Sometimes, the first frame does not have id 4 as expected... 46 | # Let's retry a few times 47 | if i == 4: 48 | # If it does not work, let's forget about dead pixels! 49 | print("Could not get the dead pixels frame!") 50 | self.dead_pixels = [] 51 | break 52 | self.init() 53 | status,ret = self.grab() 54 | if status == 4: 55 | self.dead_pixels = self.get_dead_pix_list(ret) 56 | break 57 | 58 | def get_dead_pix_list(self,data): 59 | """ 60 | Get the dead pixels image and store all the coordinates 61 | of the pixels to be corrected 62 | """ 63 | img = self.crop(np.frombuffer(data,dtype=np.uint16).reshape( 64 | RAW_HEIGHT,RAW_WIDTH)) 65 | return list(zip(*np.where(img<100))) 66 | 67 | def correct_dead_pix(self,img): 68 | """For each dead pix, take the median of the surrounding pixels""" 69 | for i,j in self.dead_pixels: 70 | img[i,j] = np.median(img[max(0,i-1):i+2,max(0,j-1):j+2]) 71 | return img 72 | 73 | def crop(self,raw_img): 74 | """Get the actual image from the raw image""" 75 | return raw_img[4:4+HEIGHT,1:1+WIDTH] 76 | 77 | def send_msg(self,bRequest, data_or_wLength, 78 | wValue=0, wIndex=0,bmRequestType=0x41,timeout=None): 79 | """ 80 | Wrapper to call ctrl_transfer with default args to enhance readability 81 | """ 82 | assert (self.dev.ctrl_transfer(bmRequestType, bRequest, wValue, wIndex, 83 | data_or_wLength, timeout) == len(data_or_wLength)) 84 | 85 | def receive_msg(self,bRequest, data, wValue=0, wIndex=0,bmRequestType=0xC1, 86 | timeout=None): 87 | """ 88 | Wrapper to call ctrl_transfer with default args to enhance readability 89 | """ 90 | return self.dev.ctrl_transfer(bmRequestType, bRequest, wValue, wIndex, 91 | data, timeout) 92 | 93 | def deinit(self): 94 | """ 95 | Is it useful ? 96 | """ 97 | for i in range(3): 98 | self.send_msg(0x3C, b'\x00\x00') 99 | 100 | def init(self): 101 | """ 102 | Sends all the necessary data to init the camera 103 | """ 104 | self.send_msg(SET_OPERATION_MODE, b'\x00\x00') 105 | #r = receive_msg(GET_FIRMWARE_INFO, 4) 106 | #print(r) 107 | #r = receive_msg(READ_CHIP_ID, 12) 108 | #print(r) 109 | self.send_msg(SET_FACTORY_SETTINGS_FEATURES, b'\x06\x00\x08\x00\x00\x00') 110 | #r = receive_msg(GET_FACTORY_SETTINGS, 12) 111 | #print(r) 112 | self.send_msg(SET_FIRMWARE_INFO_FEATURES,b'\x17\x00') 113 | #r = receive_msg(GET_FIRMWARE_INFO, 64) 114 | #print(r) 115 | self.send_msg(SET_FACTORY_SETTINGS_FEATURES, b"\x01\x00\x00\x06\x00\x00") 116 | #r = receive_msg(GET_FACTORY_SETTINGS,2) 117 | #print(r) 118 | for i in range(10): 119 | for j in range(0,256,32): 120 | self.send_msg( 121 | SET_FACTORY_SETTINGS_FEATURES,b"\x20\x00"+bytes([j,i])+b"\x00\x00") 122 | #r = receive_msg(GET_FACTORY_SETTINGS,64) 123 | #print(r) 124 | self.send_msg(SET_FIRMWARE_INFO_FEATURES,b"\x15\x00") 125 | #r = receive_msg(GET_FIRMWARE_INFO,64) 126 | #print(r) 127 | self.send_msg(SET_IMAGE_PROCESSING_MODE,b"\x08\x00") 128 | #r = receive_msg(GET_IMAGE_PROCESSING_MODE,2) 129 | #print(r) 130 | self.send_msg(SET_OPERATION_MODE,b"\x01\x00") 131 | #r = receive_msg(GET_OPERATION_MODE,2) 132 | #print(r) 133 | 134 | def grab(self): 135 | """ 136 | Asks the device for an image and reads it 137 | """ 138 | # Send read frame request 139 | self.send_msg(START_GET_IMAGE_TRANSFER, b'\x58\x5b\x01\x00') 140 | toread = 2*RAW_WIDTH*RAW_HEIGHT 141 | ret = self.dev.read(0x81, 13680, 1000) 142 | remaining = toread-len(ret) 143 | # 512 instead of 0, to avoid crashes when there is an unexpected offset 144 | # It often happens on the first frame 145 | while remaining > 512: 146 | #print(remaining," remaining") 147 | ret += self.dev.read(0x81, 13680, 1000) 148 | remaining = toread-len(ret) 149 | status = ret[4] 150 | if len(ret) == RAW_HEIGHT*RAW_WIDTH*2: 151 | return status,np.frombuffer(ret,dtype=np.uint16).reshape( 152 | RAW_HEIGHT,RAW_WIDTH) 153 | else: 154 | return status,None 155 | 156 | def get_image(self): 157 | """ 158 | Method to get an actual IR image 159 | """ 160 | while True: 161 | status,img = self.grab() 162 | #print("Status=",status) 163 | if status == 1: # Calibration frame 164 | self.calib = self.crop(img)-1600 165 | elif status == 3: # Normal frame 166 | if self.calib is not None: 167 | return self.correct_dead_pix(self.crop(img)-self.calib) 168 | 169 | 170 | if __name__ == '__main__': 171 | import cv2 172 | from time import time 173 | 174 | def rescale(img): 175 | """ 176 | To adapt the range of values to the actual min and max and cast it into 177 | an 8 bits image 178 | """ 179 | if img is None: 180 | return np.array([0]) 181 | mini = img.min() 182 | maxi = img.max() 183 | return (np.clip(img-mini,0,maxi-mini)/(maxi-mini)*255.).astype(np.uint8) 184 | 185 | cam = SeekPro() 186 | 187 | cv2.namedWindow("Seek",cv2.WINDOW_NORMAL) 188 | t0 = time() 189 | while True: 190 | t = time() 191 | print("fps:",1/(t-t0)) 192 | t0 = time() 193 | r = cam.get_image() 194 | cv2.imshow("Seek",rescale(r)) 195 | cv2.waitKey(1) 196 | --------------------------------------------------------------------------------