├── media └── screenshot.png ├── requirements.txt ├── pages ├── save_history_csv.py └── analyze_image.py ├── help.py ├── LICENSE ├── Readme.md ├── p2pro.py ├── history.py ├── .gitignore ├── extras.py ├── p2prolive_app.py └── p2pro-cmd.py /media/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ks00x/p2pro-live/HEAD/media/screenshot.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | streamlit 2 | pillow 3 | numpy 4 | plotly 5 | opencv-python 6 | pyusb 7 | ffmpeg-python -------------------------------------------------------------------------------- /pages/save_history_csv.py: -------------------------------------------------------------------------------- 1 | import streamlit as st 2 | from history import history 3 | from datetime import datetime 4 | from extras import preserve_sessionstate 5 | 6 | session = st.session_state 7 | preserve_sessionstate(session) 8 | 9 | st.write('## save the full history as a csv file') 10 | csv = session.history.csv() 11 | st.download_button('download csv file',data=csv,file_name=f'{datetime.now():%Y-%m-%d_%H:%M:%S}_history.csv') 12 | -------------------------------------------------------------------------------- /help.py: -------------------------------------------------------------------------------- 1 | 2 | history_timerange = ''' 3 | internally the data within the time range is interpolated if there are more than 1024 data points to avoid a slowdown 4 | of the app due to the plotting routine. This may give some display artifacts where the displayed data seems to change 5 | constantly. However the original data is left untouched. 6 | ''' 7 | history_wait_delay = '''add delay to reduce framerate and lower cpu usage''' 8 | 9 | cam_id = 'on windows the camera id is an integer (0,1,2..), on linux a string like /dev/..' 10 | 11 | image_width = 'in pixels, set to 0 to make the video as wide as the window (default)' 12 | 13 | tsr = 'The total cumber of the samples of the history buffer is 10000. A lower sample rate translates to a longer history and vice versa' -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Klaus Schwarzburg 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 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # P2ProLiveApp 2 | 3 | Getting the raw and video streams out of an Infiray P2Pro thermal camera and having a live thermal display on the Windows PC (Linux should work as well if the device id is changed) 4 | 5 | work in progress... 6 | 7 | 8 | Conda environment settings for Windows 9 | ```powershell 10 | conda create -n p2pro python 11 | activate p2pro 12 | pip install opencv-python pyusb pyaudio pillow plotly matplotlib streamlit 13 | # works well with streamlit 1.3 14 | ``` 15 | 16 | On Linux use venv instead of conda and pip install the same packages. For example: 17 | ```bash 18 | cd ~ 19 | mkdir venvs 20 | # create the venv: 21 | python -m venv venvs/p2pro 22 | # important - this command activates the venv: 23 | source ~/venvs/p2pro/bin/activate 24 | # to deactivate type: deactivate 25 | pip install opencv-python pyusb pyaudio pillow plotly matplotlib streamlit==1.38 26 | ``` 27 | 28 | ## features 29 | - runs on Windows and Linux without special drivers 30 | - auto and manual scaling of the temperature to color mapping 31 | - in image live display of max,min and center temperature 32 | - history chart function for min,max,avg,center temperature 33 | - save history to csv 34 | - save image to csv 35 | - (still) image viewer with zoom etc 36 | - Always and only works in the high sensitivity mode (up to 180C) 37 | 38 | run with (activate the p2pro env first): 39 | `streamlit run p2prolive_app.py` 40 | You can specifiy the device id on the commandline: 41 | `streamlit run p2prolive_app.py -- 0` 42 | or on Linux: 43 | `streamlit run p2prolive_app.py -- /dev/video1` 44 | 45 | You may create a .bat file to activate the env and click start the web app (change the folders to match your installation, note the <&> operator): 46 | ```bat 47 | activate p2pro & streamlit run d:\users\klaus\develop\python\misc\infiray\p2pro-live\p2prolive_app.py 48 | ``` 49 | 50 | 51 | ![](/media/screenshot.png) -------------------------------------------------------------------------------- /pages/analyze_image.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import streamlit as st 3 | import io 4 | import plotly.express as px 5 | from datetime import datetime 6 | from extras import preserve_sessionstate,np_to_csv_stream,c_to_f 7 | 8 | st.set_page_config('Infiray P2Pro viewer',initial_sidebar_state="expanded",page_icon='🌡',layout='wide') 9 | session = st.session_state 10 | preserve_sessionstate(session) 11 | 12 | with st.sidebar: 13 | colorscales = px.colors.named_colorscales() 14 | units = st.selectbox('units',('Celsius','Fahrenheit'),index=0) 15 | fahrenheit = False 16 | if units == 'Fahrenheit' : fahrenheit = True 17 | colorscale = st.selectbox('color map',colorscales,index=21) 18 | rotation = st.selectbox('rotate image',(0,90,180,270)) 19 | height = st.number_input('image height',value=1200,step=100) 20 | autoscale = st.checkbox('autoscale',value=True) 21 | tmin = st.number_input('min temp',value=0) 22 | tmax = st.number_input('max temp',value=60) 23 | 24 | 25 | im = session.last_image 26 | if rotation == 90 : 27 | im = np.rot90(im) 28 | if rotation == 180 : 29 | im = np.rot90(im,2) 30 | if rotation == 270 : 31 | im = np.rot90(im,3) 32 | 33 | if fahrenheit : 34 | title = 'Temperature in Fahrenheit from raw data' 35 | im = c_to_f(im) 36 | else : 37 | title = 'Temperature in Celsius from raw data' 38 | 39 | csv = np_to_csv_stream(im) 40 | st.download_button('download csv file',data=csv,file_name=f'{datetime.now():%Y-%m-%d_%H:%M:%S}_p2pro.csv') 41 | 42 | if autoscale : 43 | fig = px.imshow(im,aspect='equal',color_continuous_scale=colorscale,title=title) 44 | else : 45 | fig = px.imshow(im,aspect='equal',color_continuous_scale=colorscale,title=title,zmin=session.tmin,zmax=session.tmax) 46 | 47 | fig.update_layout(height=height) 48 | st.plotly_chart(fig,use_container_width=True) 49 | st.write(f"min = {im.min():1.2f}, max = {im.max():1.2f}, mean = {im.mean():1.2f}") 50 | 51 | 52 | -------------------------------------------------------------------------------- /p2pro.py: -------------------------------------------------------------------------------- 1 | import cv2 2 | import numpy as np 3 | import platform 4 | 5 | ''' 6 | with info from , check out: 7 | https://www.eevblog.com/forum/thermal-imaging/infiray-and-their-p2-pro-discussion/200/ 8 | https://github.com/leswright1977/PyThermalCamera/blob/main/src/tc001v4.2.py 9 | 10 | I did create a seperate conda env for this project on Windows: 11 | conda create -n p2pro python 12 | activate p2pro 13 | pip install opencv-python pyusb pyaudio ffmpeg-python 14 | 15 | tested with cv2 4.8.0 16 | ''' 17 | 18 | class p2pro: 19 | 20 | def __init__(self,cam_id) -> None: 21 | 'module to read out the Infiray P2Pro camera' 22 | if platform.system() == 'Windows': cam_id = int(cam_id) 23 | self.cap = cv2.VideoCapture(cam_id) 24 | self.cap.set(cv2.CAP_PROP_CONVERT_RGB, 0) # do not create rgb data! 25 | 26 | def get_frame(self): 27 | ret, frame = self.cap.read() 28 | if platform.system() == 'Windows': 29 | frame = np.reshape(frame[0],(2,192,256,2)) 30 | else : # Linux, MacOS 31 | frame = np.reshape(frame,(2,192,256,2)) 32 | return frame 33 | 34 | def raw(self): 35 | 'returns the raw 16bit int image' 36 | frame = self.get_frame() 37 | raw = frame[1,:,:,:].astype(np.intc) # select only the lower image part 38 | raw = (raw[:,:,1] << 8) + raw[:,:,0] # assemble the 16bit word 39 | return raw 40 | 41 | def video(self): 42 | 'returns the normal 8bit video stream from the upper half' 43 | frame = self.get_frame() 44 | return frame[0,:,:,0] # select only the upper image part, lower byte 45 | 46 | def temperature(self): 47 | 'returns the image as temperature map in Celsius' 48 | raw = self.raw() 49 | return raw/64 - 273.2 # convert to Celsius scale 50 | 51 | def __del__(self): 52 | self.cap.release() 53 | 54 | 55 | def main(): 56 | import time 57 | 58 | id = 0 # the p2pro camera may have a higher id (1,2..) 59 | id = '/dev/video0' 60 | p2 = p2pro(id) 61 | i = 0 62 | 63 | while(True): 64 | t0 = time.perf_counter() 65 | temp = p2.temperature() 66 | t1 = time.perf_counter() 67 | ct = (t1-t0)*1000 68 | i += 1 69 | if i%20 == 0 : 70 | print(f"min = {temp.min():1.4}, max = {temp.max():1.4}, avg = {temp.mean():0.4}, cpu secs read = {ct:1.2f}ms") 71 | 72 | brightness = 0.01 73 | contrast = 0.95 74 | temp = temp.T # transpose image if needed 75 | # values scaled to [0,1] range 76 | cv2.imshow('p2pro temperature',(temp-temp.min())/(temp.max()-temp.min()) * contrast + brightness) 77 | v = p2.video() 78 | cv2.imshow('video',v.T) 79 | 80 | #Waits for a user input to quit the application 81 | if cv2.waitKey(1) & 0xFF == ord('q'): 82 | break 83 | cv2.destroyAllWindows() 84 | 85 | 86 | if __name__ == '__main__': 87 | main() 88 | p2pro() -------------------------------------------------------------------------------- /history.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import time 3 | import io 4 | 5 | def hreduce(data,pts): 6 | xn = np.linspace(data[0].min(),data[0].max(),pts) 7 | n = data.shape[0] 8 | out = np.zeros((n,pts)) 9 | out[0] = xn 10 | for k in range(1,n): 11 | out[k] = np.interp(xn,data[0],data[k]) 12 | return out 13 | 14 | 15 | class history: 16 | 17 | def __init__(self,maxitems=5000,columns=3) -> None: 18 | '''A fifo buffer for numpy fp numbers with time axis in seconds 19 | The number of columns is free to choose. The total number of columns 20 | will be columns+1 21 | ''' 22 | self.maxitems = maxitems 23 | self.cols = columns 24 | self.mem = np.zeros((self.cols+1,self.maxitems),dtype=np.float32) 25 | self.items = 0 26 | self.tcreated = time.time() 27 | 28 | def length_s(self): 29 | return self.mem[0,self.items-1] 30 | 31 | def clear(self): 32 | 'clear the memory and reset the timer' 33 | self.mem = np.zeros((self.cols+1,self.maxitems),dtype=np.float32) 34 | self.items = 0 35 | self.tcreated = time.time() 36 | 37 | def head(self,num): 38 | 'get the last num elements' 39 | assert num >= 1, f'num must be >=1 , got {num}' 40 | if num > self.items : 41 | num = self.items 42 | return self.mem[:,self.items-1:self.items-num:-1] 43 | 44 | def timerange(self,range_s,offset_s=0,max_samples=500): 45 | if self.items == 0 : return None 46 | t = self.mem[0][:self.items] 47 | tmax = t[self.items-1] 48 | koff = np.searchsorted(t,tmax-offset_s) - 1 49 | if koff < 0 : koff = 0 50 | kend = np.searchsorted(t,tmax - offset_s - range_s) - 1 51 | if kend < 0 : kend = 0 52 | #print(koff,kend,self.items) 53 | if koff-kend < max_samples : 54 | #return self.mem[:,koff:kend:-1] 55 | return self.mem[:,kend:koff] 56 | else : # reduce samples for plotly 57 | return hreduce(self.mem[:,kend:koff],max_samples) 58 | 59 | def add(self,row:tuple): 60 | 'add a full row : row is a tuple with columns elements' 61 | t = time.time() - self.tcreated 62 | if self.items < self.maxitems : 63 | self.mem[:,self.items] = (t,)+row 64 | self.items += 1 65 | else: 66 | self.mem = np.roll(self.mem,-1,axis=None,) 67 | self.mem[:,self.maxitems-1] = (t,)+row 68 | 69 | def csv(self,fmt='%1.2f')->str: 70 | bio = io.BytesIO() 71 | np.savetxt(bio, self.mem[:,:self.items].T,fmt=fmt,delimiter=' ') 72 | return bio.getvalue().decode('latin1') 73 | 74 | 75 | def main(): 76 | import matplotlib.pyplot as plt 77 | 78 | fig,ax = plt.subplots() 79 | h = history(maxitems=7) 80 | for k in range(9): 81 | h.add((10+k,20+k,30+k)) 82 | time.sleep(0.0001) 83 | ax.plot(h.head(5)[0],h.head(5)[1:].T) 84 | plt.show() 85 | 86 | if __name__ == '__main__': 87 | main() -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | -------------------------------------------------------------------------------- /extras.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from PIL import Image, ImageDraw, ImageFont 3 | import io 4 | import time 5 | import platform 6 | 7 | def preserve_sessionstate(session): 8 | 'trick to preserve session state of the main page , see: https://discuss.streamlit.io/t/preserving-state-across-sidebar-pages/107/23 ' 9 | for k in session.keys(): 10 | session[k] = session[k] 11 | 12 | def np_to_csv_stream(im,fmt='%1.2f')->str: 13 | bio = io.BytesIO() 14 | np.savetxt(bio,im,fmt=fmt,delimiter=' ') 15 | return bio.getvalue().decode('latin1') 16 | 17 | def c_to_f(x): 18 | return x * 1.8 + 32 19 | 20 | 21 | def find_tmax(temp): 22 | 'finds the max of the 2D array and returns a tuple ((x,y),value) of coordinates and value at position' 23 | m = np.argmax(temp) 24 | m = np.unravel_index(m, np.array(temp).shape) 25 | return (m[1],m[0]) , temp[m] 26 | 27 | def find_tmin(temp): 28 | 'finds the min of the 2D array and returns a tuple ((x,y),value) of coordinates and value at position' 29 | m = np.argmin(temp) 30 | m = np.unravel_index(m, np.array(temp).shape) 31 | return (m[1],m[0]) , temp[m] 32 | 33 | def rotate(temp,rot): 34 | if rot == 90 : return np.rot90(temp,1) 35 | if rot == 180 : return np.rot90(temp,2) 36 | if rot == 270 : return np.rot90(temp,3) 37 | return temp 38 | 39 | def draw_annotation(image,pos,text,color='red',fontsize=15,dotsize=4): 40 | '''PIL image - draws a circle at the location and the annotation next to it. 41 | Checks for image borders and adjusts the the text position so the text remains visible 42 | 43 | ''' 44 | # check that the font is actually available! 45 | if platform.system() == 'Windows': 46 | fonttype='arial.ttf' 47 | else : 48 | fonttype='DejaVuSans.ttf' 49 | 50 | draw = ImageDraw.Draw(image) 51 | s = dotsize/2 52 | x1 = abs(pos[0]-s) 53 | y1 = abs(pos[1]-s) 54 | x2 = abs(pos[0]+s) 55 | y2 = abs(pos[1]+s) 56 | draw.ellipse((x1,y1,x2,y2), fill=color, 57 | outline=color, width=1) 58 | 59 | font = ImageFont.truetype(fonttype, fontsize,) 60 | tl = int(draw.textlength(text,font)) 61 | # give some offset if text is near the border: 62 | w,h = image.size 63 | if x1 + tl > w : x = pos[0] - tl 64 | else : x = pos[0] 65 | if y1 + fontsize > h : y = pos[1] - fontsize 66 | else : y = pos[1] 67 | 68 | draw.text((x,y),text,fill=color,font=font ) 69 | del draw # potential memory leak here! 70 | 71 | 72 | def convert_colormap(temp,colormapper): 73 | temp = colormapper(temp) 74 | temp = np.uint8(temp * 255) 75 | im = Image.fromarray(temp) 76 | return im 77 | 78 | 79 | def colorbarfig(min,max,cmapname): 80 | 'draw a colorbar with scale only image' 81 | # https://matplotlib.org/stable/users/explain/colors/colorbar_only.html 82 | from matplotlib import cm,colors,figure 83 | # solves memory problems calling it this way! 84 | # see https://discourse.matplotlib.org/t/pyplot-interface-and-memory-management/22299 85 | fig = figure.Figure(figsize=(1, 8), layout='constrained') 86 | ax = fig.subplots(1, 1) 87 | norm = colors.Normalize(vmin=min, vmax=max) 88 | fig.colorbar(cm.ScalarMappable(norm=norm, cmap=cmapname), 89 | cax=ax, orientation='vertical') # , label='temperature' 90 | ax.tick_params(labelsize=16) 91 | return fig 92 | 93 | 94 | class mytimer: 95 | '''A simple timer class that checks the time passed against a 96 | a predefined interval for each item 97 | ''' 98 | def __init__(self) -> None: 99 | self.evts = {} 100 | def add(self,name:str,interval_s:float): 101 | self.evts[name] = [time.time(),interval_s] 102 | def check(self,name)->bool: 103 | t = time.time() 104 | v = self.evts[name] 105 | if t - v[0] >= v[1] : 106 | v[0] = t 107 | return True 108 | return False -------------------------------------------------------------------------------- /p2prolive_app.py: -------------------------------------------------------------------------------- 1 | import streamlit as st 2 | import numpy as np 3 | import time 4 | import cv2 5 | from PIL import Image 6 | import plotly.express as px 7 | import matplotlib.pyplot as plt 8 | from history import history 9 | from p2pro import p2pro 10 | from extras import find_tmin,find_tmax,draw_annotation,rotate,preserve_sessionstate,colorbarfig,mytimer 11 | import help 12 | import sys 13 | import platform 14 | 15 | st.set_page_config('P2Pro LIVE',initial_sidebar_state='expanded',page_icon='🔺',layout='wide') 16 | session = st.session_state 17 | 18 | # https://matplotlib.org/stable/gallery/color/colormap_reference.html 19 | cmaplist = ['jet','gray','bone', 'cividis','rainbow','terrain','nipy_spectral','gist_ncar','brg','hot','plasma', 'viridis','inferno', 'magma','afmhot','tab20c'] 20 | 21 | HISTORY_LEN = 10000 # length of the history buffer in samples 22 | if 'history' not in session : # init and set default values for sidebar controls 23 | session.history = history(maxitems=HISTORY_LEN,columns=4) 24 | session.tsr = 2. 25 | if platform.system() == 'Windows': 26 | session.id = '1' 27 | else: 28 | session.id = '/dev/video0' 29 | session.brightness = 0. 30 | session.contrast = 1. 31 | session.sharp = 0 32 | session.rotate = 0 33 | session.annotations = False 34 | session.autoscale = True 35 | session.tmin = 20. 36 | session.tmax = 60 37 | session.timeline = True 38 | session.show_min = True 39 | session.show_max = True 40 | session.show_mean = True 41 | session.show_center = True 42 | session.trange = 500. 43 | session.toff = 0. 44 | session.width = 0 45 | session.cheight = 300 46 | session.wait_delay = 50 47 | session.t_units = 's' 48 | session.showscale = True 49 | 50 | if len(sys.argv) > 1 : # cmdline overwrite for the device id, use '--' in front of the argument! 51 | session.id = sys.argv[1] 52 | 53 | else : 54 | preserve_sessionstate(session) 55 | 56 | def restart(): 57 | init.clear() 58 | 59 | with st.sidebar: 60 | with st.expander('color scaling',expanded=True): 61 | st.checkbox('autoscale',key='autoscale') 62 | st.number_input('max T',key='tmax') 63 | st.number_input('min T',key='tmin') 64 | st.selectbox('colormap',cmaplist,key='colormap') 65 | with st.expander('image controls',expanded=True): 66 | # st.slider('brightness',min_value=0.,max_value=1.,key='brightness') 67 | # st.slider('contrast',min_value=0.1,max_value=1.,key='contrast') 68 | st.slider('sharpness',min_value=-6,max_value=1,key='sharp') 69 | st.selectbox('rotate image',(0,90,180,270),key='rotate') 70 | st.checkbox('show min max temp cursors',key='annotations') 71 | st.checkbox('show color scale',key='showscale') 72 | st.checkbox('show normal video stream',key='showvideo') 73 | with st.expander('history settings',expanded=session.timeline): 74 | st.checkbox('show history timeline',key='timeline') 75 | c1,c2,c3 = st.columns(3) 76 | c1.checkbox('min',key='show_min') 77 | c2.checkbox('max',key='show_max') 78 | c3.checkbox('mean',key='show_mean') 79 | c1,_,_ = st.columns(3) 80 | c1.checkbox('center',key='show_center') 81 | st.number_input('time range in s',help=help.history_timerange,key='trange') 82 | st.slider('time offset in s',min_value=0.,max_value=3600.,key='toff') 83 | st.radio('time units',('s','m'),horizontal=True,key='t_units') 84 | st.number_input('history sample rate Hz',max_value=10.,min_value=0.1,key='tsr',help=help.tsr) 85 | if st.button('clear history') : 86 | session.history.clear() 87 | with st.expander('more settings'): 88 | st.text_input('camera id',on_change=restart,key='id',help=help.cam_id) 89 | st.number_input('image width',step=50,key='width',help=help.image_width) 90 | st.number_input('chart height',step=50,key='cheight') 91 | st.number_input('wait delay ms',min_value=0,step=10,help=help.history_wait_delay,key='wait_delay') 92 | 93 | @st.cache_resource 94 | def init(): 95 | p2 = p2pro(session.id) 96 | try: 97 | p2.raw() 98 | except Exception: 99 | st.error(f'this seems to be no P2Pro cam ☹. Check connections and the camera id string, currently: {session.id}') 100 | return p2 101 | 102 | p2 = init() 103 | 104 | ##### define placeholders for the loop output: 105 | info = st.empty() 106 | chart = st.empty() 107 | if session.showscale : 108 | c1,c2 = st.columns((0.9,0.1)) 109 | img = c1.empty() 110 | img_cbar = c2.empty() 111 | else: 112 | img = st.empty() 113 | img2 = st.empty() 114 | 115 | cm_hot = plt.get_cmap(session.colormap) 116 | 117 | tm = mytimer() 118 | tm.add('chart',1/session.tsr) 119 | tm.add('history',1/session.tsr) 120 | tm.add('colorbar',0.5) 121 | tm.add('restart',500) 122 | 123 | while True: # main aquisition loop 124 | 125 | temp = p2.temperature() 126 | temp = rotate(temp,session.rotate) 127 | session.last_image = temp 128 | 129 | if session.annotations : 130 | idxmax,ma = find_tmax(temp) 131 | idxmin,mi = find_tmin(temp) 132 | idc = (temp.shape[1]//2,temp.shape[0]//2) 133 | mc = temp[idc[::-1]] # why is that reverse needed??? 134 | 135 | stat = (temp.min(),temp.max(),temp.mean(),mc) 136 | if tm.check('history') : # add history data 137 | session.history.add(stat) 138 | 139 | if session.timeline and tm.check('chart'):# The chart display increases cpu load. ~2 updates/s 140 | data = session.history.timerange(session.trange,session.toff,max_samples=1024) 141 | if data is not None: 142 | fig = px.line(x=None, y=None,height=session.cheight) 143 | if session.t_units == 'm' : 144 | t = data[0]/60 145 | labels = {'xaxis_title':"time in minutes",'yaxis_title':"temperature in C"} 146 | else : 147 | t = data[0] 148 | labels = {'xaxis_title':"time in seconds",'yaxis_title':"temperature in C"} 149 | fig.update_layout(labels) 150 | if session.show_min : fig.add_scatter(x=t, y=data[1],mode='lines',name='min',line=dict(color="blue")) 151 | if session.show_max : fig.add_scatter(x=t, y=data[2],mode='lines',name='max',line=dict(color="red")) 152 | if session.show_mean : fig.add_scatter(x=t, y=data[3],mode='lines',name='mean',line=dict(color="green")) 153 | if session.show_center : fig.add_scatter(x=t, y=data[4],mode='lines',name='center',line=dict(color="orange")) 154 | chart.plotly_chart(fig,use_container_width=True) 155 | 156 | c1,c2,c3,c4 = info.columns(4) 157 | c1.metric('min',value=f"{stat[0]:1.4}C") 158 | c2.metric('max',value=f"{stat[1]:1.4}C") 159 | c3.metric('avg',value=f"{stat[2]:1.4}C") 160 | c4.metric('center',value=f"{stat[3]:1.4}C") 161 | 162 | # from here on we start to mod the temp data! 163 | if session.sharp < 0: 164 | temp = cv2.blur(temp, (-session.sharp+1,-session.sharp+1)) 165 | if session.sharp >= 1 : 166 | kernel = np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]]) 167 | temp = cv2.filter2D(temp, -1, kernel) 168 | 169 | if session.autoscale : 170 | temp = (temp-stat[0])/(stat[1]-stat[0]) * session.contrast + session.brightness 171 | if session.showscale and tm.check('colorbar') : 172 | f = colorbarfig(stat[0],stat[1],session.colormap) 173 | img_cbar.pyplot(f) 174 | del f 175 | else : 176 | temp = (temp-session.tmin)/(session.tmax-session.tmin) * session.contrast + session.brightness 177 | if session.showscale and tm.check('colorbar'): 178 | f = colorbarfig(session.tmin,session.tmax,session.colormap) 179 | img_cbar.pyplot(f) 180 | del f 181 | # trick to use the matplotlib colormaps with PIL 182 | temp = cm_hot(temp) 183 | temp = np.uint8(temp * 255) 184 | im = Image.fromarray(temp) 185 | 186 | if session.annotations : 187 | draw_annotation(im,idxmax,f'{ma:1.2f}C') 188 | draw_annotation(im,idxmin,f'{mi:1.2f}C',color='lightblue') 189 | draw_annotation(im,idc,f'{mc:1.2f}C',color='lightblue') 190 | 191 | if session.width > 0 : 192 | img.image(im,width=session.width,clamp=True,) 193 | else : 194 | img.image(im,clamp=True,use_column_width=True) 195 | 196 | if session.showvideo : 197 | v = p2.video() 198 | v = rotate(v,session.rotate) 199 | if session.width > 0 : 200 | img2.image(v,width=session.width,clamp=True,) 201 | else : 202 | img2.image(v,clamp=True,use_column_width=True) 203 | 204 | time.sleep(session.wait_delay/1000.) 205 | 206 | if tm.check('restart') : # memory leak in streamlit 207 | st.rerun() 208 | 209 | -------------------------------------------------------------------------------- /p2pro-cmd.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import struct 3 | import time 4 | import logging 5 | 6 | import usb.util 7 | import usb.core 8 | 9 | log = logging.getLogger(__name__) 10 | 11 | 12 | class PseudoColorTypes(enum.IntEnum): 13 | PSEUDO_WHITE_HOT = 1 14 | PSEUDO_RESERVED = 2 15 | PSEUDO_IRON_RED = 3 16 | PSEUDO_RAINBOW_1 = 4 17 | PSEUDO_RAINBOW_2 = 5 18 | PSEUDO_RAINBOW_3 = 6 19 | PSEUDO_RED_HOT = 7 20 | PSEUDO_HOT_RED = 8 21 | PSEUDO_RAINBOW_4 = 9 22 | PSEUDO_RAINBOW_5 = 10 23 | PSEUDO_BLACK_HOT = 11 24 | # WHITE_HOT_MODE = 16 # unsure what the modes do, but it returns an error when trying to set 25 | # BLACK_HOT_MODE = 17 26 | # RAINBOW_MODE = 18 27 | # IRONBOW_MODE = 19 28 | # AURORA_MODE = 20 29 | # JUNGLE_MODE = 21 30 | # GLORY_HOT_MODE = 22 31 | # MEDICAL_MODE = 23 32 | # NIGHT_MODE = 24 33 | # SEPIA_MODE = 25 34 | # RED_HOT_MODE = 26 35 | 36 | 37 | class PropTpdParams(enum.IntEnum): 38 | TPD_PROP_DISTANCE = 0 # 1/163.835 m, 0-32767, Distance 39 | TPD_PROP_TU = 1 # 1 K, 0-1024, Reflection temperature 40 | TPD_PROP_TA = 2 # 1 K, 0-1024, Atmospheric temperature 41 | TPD_PROP_EMS = 3 # 1/127, 0-127, Emissivity 42 | TPD_PROP_TAU = 4 # 1/127, 0-127, Atmospheric transmittance 43 | TPD_PROP_GAIN_SEL = 5 # binary, 0-1, Gain select (0=low, 1=high) 44 | 45 | 46 | class DeviceInfoType(enum.IntEnum): 47 | DEV_INFO_CHIP_ID = 0 48 | DEV_INFO_FW_COMPILE_DATE = 1 49 | DEV_INFO_DEV_QUALIFICATION = 2 50 | DEV_INFO_IR_INFO = 3 51 | DEV_INFO_PROJECT_INFO = 4 52 | DEV_INFO_FW_BUILD_VERSION_INFO = 5 53 | DEV_INFO_GET_PN = 6 54 | DEV_INFO_GET_SN = 7 55 | DEV_INFO_GET_SENSOR_ID = 8 56 | DeviceInfoType_len = [8, 8, 8, 26, 4, 50, 48, 16, 4] # crudely implement the different lengths of the different types 57 | 58 | 59 | class CmdDir(enum.IntFlag): 60 | GET = 0x0000 61 | SET = 0x4000 62 | 63 | 64 | class CmdCode(enum.IntEnum): 65 | sys_reset_to_rom = 0x0805 66 | spi_transfer = 0x8201 67 | get_device_info = 0x8405 68 | pseudo_color = 0x8409 69 | shutter_vtemp = 0x840c 70 | prop_tpd_params = 0x8514 71 | cur_vtemp = 0x8b0d 72 | preview_start = 0xc10f 73 | preview_stop = 0x020f 74 | y16_preview_start = 0x010a 75 | y16_preview_stop = 0x020a 76 | 77 | 78 | class P2Pro: 79 | _dev: usb.core.Device 80 | 81 | def __init__(self): 82 | self._dev = usb.core.find(idVendor=0x0BDA, idProduct=0x5830) 83 | if (self._dev == None): 84 | raise FileNotFoundError("Infiray P2 Pro thermal module not found, please connect and try again!") 85 | pass 86 | 87 | def _check_camera_ready(self) -> bool: 88 | """ 89 | Checks if the camera is ready (i2c_usb_check_access_done in the SDK) 90 | 91 | :return: True if the camera is ready 92 | :raises UserWarning: When the return code of the camera is abnormal 93 | """ 94 | ret = self._dev.ctrl_transfer(0xC1, 0x44, 0x78, 0x200, 1) 95 | if (ret[0] & 1 == 0 and ret[0] & 2 == 0): 96 | return True 97 | if (ret[0] & 0xFC != 0): 98 | raise UserWarning(f"vdcmd status error {ret[0]:#X}") 99 | return False 100 | 101 | def _block_until_camera_ready(self, timeout: int = 5) -> bool: 102 | """ 103 | Blocks until the camera is ready or the timeout is reached 104 | 105 | :param timeout: Timeout in seconds 106 | :return: True if the camera is ready, False if the timout occured 107 | :raises UserWarning: When the return code of the camera is abnormal 108 | """ 109 | start = time.time() 110 | while True: 111 | if (self._check_camera_ready()): 112 | return True 113 | time.sleep(0.001) 114 | if (time.time() > start + timeout): 115 | return False 116 | 117 | def _long_cmd_write(self, cmd: int, p1: int, p2: int, p3: int = 0, p4: int = 0): 118 | data1 = struct.pack("HI", p1, p2) 120 | data2 = struct.pack(">II", p3, p4) 121 | log.debug(f'l_cmd_w {0x9d00:#x} {data1.hex()}') 122 | log.debug(f'l_cmd_w {0x1d08:#x} {data2.hex()} ') 123 | self._dev.ctrl_transfer(0x41, 0x45, 0x78, 0x9d00, data1) 124 | self._dev.ctrl_transfer(0x41, 0x45, 0x78, 0x1d08, data2) 125 | self._block_until_camera_ready() 126 | 127 | def _long_cmd_read(self, cmd: int, p1: int, p2: int = 0, p3: int = 0, dataLen: int = 2): 128 | data1 = struct.pack("HI", p1, p2) 130 | data2 = struct.pack(">II", p3, dataLen) 131 | log.debug(f'l_cmd_r {0x9d00:#x} {data1.hex()}') 132 | log.debug(f'l_cmd_r {0x1d08:#x} {data2.hex()} ') 133 | self._dev.ctrl_transfer(0x41, 0x45, 0x78, 0x9d00, data1) 134 | self._dev.ctrl_transfer(0x41, 0x45, 0x78, 0x1d08, data2) 135 | self._block_until_camera_ready() 136 | log.debug(f'l_cmd_r {0x1d10:#x} ...') 137 | res = self._dev.ctrl_transfer(0xC1, 0x44, 0x78, 0x1d10, dataLen) 138 | return bytes(res) 139 | 140 | def _standard_cmd_write(self, cmd: int, cmd_param: int = 0, data: bytes = b'\x00', dataLen: int = -1): 141 | """ 142 | Sends a "standard CMD write" packet 143 | 144 | :param cmd: 2 byte CMD code 145 | :param cmd_param: 4 byte parameter that gets sent together with CMD (for spi_* commands, the address needs to be passed in as big-endian) 146 | :param data: payload 147 | :param dataLen: payload length 148 | """ 149 | if dataLen == -1: 150 | dataLen = len(data) 151 | 152 | cmd_param = struct.unpack('I', cmd_param))[0] # switch endinanness 153 | 154 | # If there is no payload, send the 8 byte command immediately 155 | if (dataLen == 0 or data == b'\x00'): 156 | # send 1d00 with cmd 157 | d = struct.pack("I2x", cmd_param) 159 | log.debug(f's_cmd_w {0x1d00:#x} ({len(d):2}) {d.hex()}') 160 | self._dev.ctrl_transfer(0x41, 0x45, 0x78, 0x1d00, d) 161 | self._block_until_camera_ready() 162 | return 163 | 164 | outer_chunk_size = 0x100 165 | inner_chunk_size = 0x40 166 | 167 | # A "camera command" can be 256 bytes long max, but we can split the data and 168 | # send more with an incremented address parameter (only spi_read/write actually uses that afaik) 169 | # (adress parameter is big endian, but others are either little endian or only one byte in initial_data[2]) 170 | for i in range(0, dataLen, outer_chunk_size): 171 | outer_chunk = data[i:i+outer_chunk_size] 172 | 173 | # Send initial "camera command" 174 | initial_data = struct.pack("IH", cmd_param + i, len(outer_chunk)) 176 | log.debug(f's_cmd_w {0x9d00:#x} ({len(initial_data):2}) {initial_data.hex()}') 177 | self._dev.ctrl_transfer(0x41, 0x45, 0x78, 0x9d00, initial_data) 178 | self._block_until_camera_ready() 179 | 180 | # Each vendor control transfer can be 64 bytes max. Split up and send with incrementing wIndex value 181 | for j in range(0, len(outer_chunk), inner_chunk_size): 182 | inner_chunk = outer_chunk[j:j+inner_chunk_size] 183 | to_send = len(outer_chunk) - j 184 | 185 | # The logic for splitting up long vendor requests is a bit weird 186 | # I just reimplemented it like Infiray did according to the USB trace. Don't want to cause unnecessary problems 187 | if (to_send <= 8): 188 | log.debug(f's_cmd_w {(0x1d08 + j):#x} ({len(inner_chunk):2}) {inner_chunk.hex()}') 189 | self._dev.ctrl_transfer(0x41, 0x45, 0x78, 0x1d08 + j, inner_chunk) 190 | self._block_until_camera_ready() 191 | elif (to_send <= 64): 192 | log.debug(f's_cmd_w {(0x9d08 + j):#x} ({len(inner_chunk[:-8]):2}) {inner_chunk[:-8].hex()}') 193 | log.debug( 194 | f's_cmd_w {(0x1d08 + j + to_send - 8):#x} ({len(inner_chunk[-8:]):2}) {inner_chunk[-8:].hex()}') 195 | self._dev.ctrl_transfer(0x41, 0x45, 0x78, 0x9d08 + j, inner_chunk[:-8]) 196 | self._dev.ctrl_transfer(0x41, 0x45, 0x78, 0x1d08 + j + to_send - 8, inner_chunk[-8:]) 197 | self._block_until_camera_ready() 198 | else: 199 | log.debug(f's_cmd_w {(0x9d08 + j):#x} ({len(inner_chunk):2}) {inner_chunk.hex()}') 200 | self._dev.ctrl_transfer(0x41, 0x45, 0x78, 0x9d08 + j, inner_chunk) 201 | 202 | # pretty similar to _standard_cmd_write, but a bit simpler 203 | 204 | def _standard_cmd_read(self, cmd: int, cmd_param: int = 0, dataLen: int = 0) -> bytes: 205 | """ 206 | Sends a "standard CMD read" packet 207 | 208 | :param cmd: 2 byte CMD code 209 | :param cmd_param: 4 byte parameter that gets sent together with CMD (for spi_* commands, the address needs to be passed in as big-endian) 210 | :param dataLen: read length 211 | :return: bytes object containing the read result 212 | """ 213 | if dataLen == 0: 214 | return b'' 215 | 216 | cmd_param = struct.unpack('I', cmd_param))[0] # switch endinanness 217 | 218 | result = b'' 219 | outer_chunk_size = 0x100 220 | # A "camera command" can be 256 bytes long max, but we can split the data and 221 | # read more with an incremented address parameter (only spi_read/write actually uses that afaik) 222 | for i in range(0, dataLen, outer_chunk_size): 223 | to_read = min(dataLen - i, outer_chunk_size) 224 | # Send initial "camera command" 225 | initial_data = struct.pack("IH", cmd_param + i, to_read) 227 | log.debug(f's_cmd_r {0x1d00:#x} ({len(initial_data):2}) {initial_data.hex()}') 228 | self._dev.ctrl_transfer(0x41, 0x45, 0x78, 0x1d00, initial_data) 229 | self._block_until_camera_ready() 230 | 231 | # read request (USB: 0xC1, 0x44) 232 | log.debug(f's_cmd_r {0x1d08:#x} ({to_read:2}) ...') 233 | res = self._dev.ctrl_transfer(0xC1, 0x44, 0x78, 0x1d08, to_read) 234 | result += bytes(res) 235 | 236 | return result 237 | 238 | def pseudo_color_set(self, preview_path: int, color_type: PseudoColorTypes): 239 | self._standard_cmd_write((CmdCode.pseudo_color | CmdDir.SET), preview_path, struct.pack(" PseudoColorTypes: 242 | res = self._standard_cmd_read(CmdCode.pseudo_color, preview_path, 1) 243 | return PseudoColorTypes(int.from_bytes(res, 'little')) 244 | 245 | def set_prop_tpd_params(self, tpd_param: PropTpdParams, value: int): 246 | self._long_cmd_write(CmdCode.prop_tpd_params | CmdDir.SET, tpd_param, value) 247 | 248 | def get_prop_tpd_params(self, tpd_param: PropTpdParams) -> int: 249 | res = self._long_cmd_read(CmdCode.prop_tpd_params, tpd_param) 250 | return struct.unpack(">H", res)[0] 251 | 252 | def get_device_info(self, dev_info: DeviceInfoType): 253 | res = self._standard_cmd_read(CmdCode.get_device_info, dev_info, DeviceInfoType_len[dev_info]) 254 | return res 255 | 256 | 257 | if __name__ == '__main__': 258 | 259 | cam_cmd = P2Pro() 260 | print(cam_cmd) 261 | print(cam_cmd._standard_cmd_read(CmdCode.cur_vtemp, 0, 2)) 262 | #print(cam_cmd._standard_cmd_read(CmdCode.shutter_vtemp, 0, 2)) 263 | #cam_cmd.pseudo_color_set(0, PseudoColorTypes.PSEUDO_IRON_RED) --------------------------------------------------------------------------------