├── media ├── media.lst ├── csv.png ├── mini.png ├── pi.png ├── parts.png ├── scope.png ├── uncal.png ├── console.png ├── minispect.png ├── calibrated.png ├── pointsadded.png ├── terbium-measure.png ├── high-pressure-sodium.png ├── spectrum-20221013--210412.png ├── spectrum-20221016--144134.png ├── spectrum-20221016--144215.png ├── spectrum-20221016--144325.png ├── spectrum-20221016--144355.png ├── waterfall-20221013--205708.png ├── waterfall-20221013--205840.png ├── waterfall-20221013--210412.png ├── Screenshot_2022-10-16_14-41-14.png └── Screenshot_2022-10-16_14-43-11.png ├── src ├── filelist.txt ├── PySpectrometer2-USB-v1.0.py ├── PySpectrometer2-Picam2-v1.0.py └── specFunctions.py ├── LICENCE └── README.md /media/media.lst: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/filelist.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /media/csv.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leswright1977/PySpectrometer2/HEAD/media/csv.png -------------------------------------------------------------------------------- /media/mini.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leswright1977/PySpectrometer2/HEAD/media/mini.png -------------------------------------------------------------------------------- /media/pi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leswright1977/PySpectrometer2/HEAD/media/pi.png -------------------------------------------------------------------------------- /media/parts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leswright1977/PySpectrometer2/HEAD/media/parts.png -------------------------------------------------------------------------------- /media/scope.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leswright1977/PySpectrometer2/HEAD/media/scope.png -------------------------------------------------------------------------------- /media/uncal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leswright1977/PySpectrometer2/HEAD/media/uncal.png -------------------------------------------------------------------------------- /media/console.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leswright1977/PySpectrometer2/HEAD/media/console.png -------------------------------------------------------------------------------- /media/minispect.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leswright1977/PySpectrometer2/HEAD/media/minispect.png -------------------------------------------------------------------------------- /media/calibrated.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leswright1977/PySpectrometer2/HEAD/media/calibrated.png -------------------------------------------------------------------------------- /media/pointsadded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leswright1977/PySpectrometer2/HEAD/media/pointsadded.png -------------------------------------------------------------------------------- /media/terbium-measure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leswright1977/PySpectrometer2/HEAD/media/terbium-measure.png -------------------------------------------------------------------------------- /media/high-pressure-sodium.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leswright1977/PySpectrometer2/HEAD/media/high-pressure-sodium.png -------------------------------------------------------------------------------- /media/spectrum-20221013--210412.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leswright1977/PySpectrometer2/HEAD/media/spectrum-20221013--210412.png -------------------------------------------------------------------------------- /media/spectrum-20221016--144134.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leswright1977/PySpectrometer2/HEAD/media/spectrum-20221016--144134.png -------------------------------------------------------------------------------- /media/spectrum-20221016--144215.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leswright1977/PySpectrometer2/HEAD/media/spectrum-20221016--144215.png -------------------------------------------------------------------------------- /media/spectrum-20221016--144325.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leswright1977/PySpectrometer2/HEAD/media/spectrum-20221016--144325.png -------------------------------------------------------------------------------- /media/spectrum-20221016--144355.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leswright1977/PySpectrometer2/HEAD/media/spectrum-20221016--144355.png -------------------------------------------------------------------------------- /media/waterfall-20221013--205708.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leswright1977/PySpectrometer2/HEAD/media/waterfall-20221013--205708.png -------------------------------------------------------------------------------- /media/waterfall-20221013--205840.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leswright1977/PySpectrometer2/HEAD/media/waterfall-20221013--205840.png -------------------------------------------------------------------------------- /media/waterfall-20221013--210412.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leswright1977/PySpectrometer2/HEAD/media/waterfall-20221013--210412.png -------------------------------------------------------------------------------- /media/Screenshot_2022-10-16_14-41-14.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leswright1977/PySpectrometer2/HEAD/media/Screenshot_2022-10-16_14-41-14.png -------------------------------------------------------------------------------- /media/Screenshot_2022-10-16_14-43-11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leswright1977/PySpectrometer2/HEAD/media/Screenshot_2022-10-16_14-43-11.png -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/PySpectrometer2-USB-v1.0.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | ''' 4 | PySpectrometer2 Les Wright 2022 5 | https://www.youtube.com/leslaboratory 6 | https://github.com/leswright1977 7 | 8 | This project is a follow on from: https://github.com/leswright1977/PySpectrometer 9 | 10 | This is a more advanced, but more flexible version of the original program. Tk Has been dropped as the GUI to allow fullscreen mode on Raspberry Pi systems and the iterface is designed to fit 800*480 screens, which seem to be a common resolutin for RPi LCD's, paving the way for the creation of a stand alone benchtop instrument. 11 | 12 | Whats new: 13 | Higher resolution (800px wide graph) 14 | 3 row pixel averaging of sensor data 15 | Fullscreen option for the Spectrometer graph 16 | 3rd order polymonial fit of calibration data for accurate measurement. 17 | Improved graph labelling 18 | Labelled measurement cursors 19 | Optional waterfall display for recording spectra changes over time. 20 | Key Bindings for all operations 21 | 22 | All old features have been kept, including peak hold, peak detect, Savitsky Golay filter, and the ability to save graphs as png and data as CSV. 23 | 24 | For instructions please consult the readme! 25 | 26 | ''' 27 | 28 | 29 | import cv2 30 | import time 31 | import numpy as np 32 | from specFunctions import wavelength_to_rgb,savitzky_golay,peakIndexes,readcal,writecal,background,generateGraticule 33 | import base64 34 | import argparse 35 | 36 | parser = argparse.ArgumentParser() 37 | parser.add_argument("--device", type=int, default=0, help="Video Device number e.g. 0, use v4l2-ctl --list-devices") 38 | parser.add_argument("--fps", type=int, default=30, help="Frame Rate e.g. 30") 39 | group = parser.add_mutually_exclusive_group() 40 | group.add_argument("--fullscreen", help="Fullscreen (Native 800*480)",action="store_true") 41 | group.add_argument("--waterfall", help="Enable Waterfall (Windowed only)",action="store_true") 42 | args = parser.parse_args() 43 | dispFullscreen = False 44 | dispWaterfall = False 45 | if args.fullscreen: 46 | print("Fullscreen Spectrometer enabled") 47 | dispFullscreen = True 48 | if args.waterfall: 49 | print("Waterfall display enabled") 50 | dispWaterfall = True 51 | 52 | if args.device: 53 | dev = args.device 54 | else: 55 | dev = 0 56 | 57 | if args.fps: 58 | fps = args.fps 59 | else: 60 | fps = 30 61 | 62 | frameWidth = 800 63 | frameHeight = 600 64 | 65 | 66 | #init video 67 | cap = cv2.VideoCapture('/dev/video'+str(dev), cv2.CAP_V4L) 68 | #cap = cv2.VideoCapture(0) 69 | print("[info] W, H, FPS") 70 | cap.set(cv2.CAP_PROP_FRAME_WIDTH,frameWidth) 71 | cap.set(cv2.CAP_PROP_FRAME_HEIGHT,frameHeight) 72 | cap.set(cv2.CAP_PROP_FPS,fps) 73 | print(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) 74 | print(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) 75 | print(cap.get(cv2.CAP_PROP_FPS)) 76 | cfps = (cap.get(cv2.CAP_PROP_FPS)) 77 | 78 | 79 | title1 = 'PySpectrometer 2 - Spectrograph' 80 | title2 = 'PySpectrometer 2 - Waterfall' 81 | stackHeight = 320+80+80 #height of the displayed CV window (graph+preview+messages) 82 | 83 | if dispWaterfall == True: 84 | #watefall first so spectrum is on top 85 | cv2.namedWindow(title2,cv2.WINDOW_GUI_NORMAL) 86 | cv2.resizeWindow(title2,frameWidth,stackHeight) 87 | cv2.moveWindow(title2,200,200); 88 | 89 | if dispFullscreen == True: 90 | cv2.namedWindow(title1,cv2.WND_PROP_FULLSCREEN) 91 | cv2.setWindowProperty(title1,cv2.WND_PROP_FULLSCREEN,cv2.WINDOW_FULLSCREEN) 92 | else: 93 | cv2.namedWindow(title1,cv2.WINDOW_GUI_NORMAL) 94 | cv2.resizeWindow(title1,frameWidth,stackHeight) 95 | cv2.moveWindow(title1,0,0); 96 | 97 | #settings for peak detect 98 | savpoly = 7 #savgol filter polynomial max val 15 99 | mindist = 50 #minumum distance between peaks max val 100 100 | thresh = 20 #Threshold max val 100 101 | 102 | calibrate = False 103 | 104 | clickArray = [] 105 | cursorX = 0 106 | cursorY = 0 107 | def handle_mouse(event,x,y,flags,param): 108 | global clickArray 109 | global cursorX 110 | global cursorY 111 | mouseYOffset = 160 112 | if event == cv2.EVENT_MOUSEMOVE: 113 | cursorX = x 114 | cursorY = y 115 | if event == cv2.EVENT_LBUTTONDOWN: 116 | mouseX = x 117 | mouseY = y-mouseYOffset 118 | clickArray.append([mouseX,mouseY]) 119 | #listen for click on plot window 120 | cv2.setMouseCallback(title1,handle_mouse) 121 | 122 | 123 | font=cv2.FONT_HERSHEY_SIMPLEX 124 | 125 | intensity = [0] * frameWidth #array for intensity data...full of zeroes 126 | 127 | holdpeaks = False #are we holding peaks? 128 | measure = False #are we measuring? 129 | recPixels = False #are we measuring pixels and recording clicks? 130 | 131 | 132 | #messages 133 | msg1 = "" 134 | saveMsg = "No data saved" 135 | 136 | #blank image for Waterfall 137 | waterfall = np.zeros([320,frameWidth,3],dtype=np.uint8) 138 | waterfall.fill(0) #fill black 139 | 140 | #Go grab the computed calibration data 141 | caldata = readcal(frameWidth) 142 | wavelengthData = caldata[0] 143 | calmsg1 = caldata[1] 144 | calmsg2 = caldata[2] 145 | calmsg3 = caldata[3] 146 | 147 | #generate the craticule data 148 | graticuleData = generateGraticule(wavelengthData) 149 | tens = (graticuleData[0]) 150 | fifties = (graticuleData[1]) 151 | 152 | def snapshot(savedata): 153 | now = time.strftime("%Y%m%d--%H%M%S") 154 | timenow = time.strftime("%H:%M:%S") 155 | imdata1 = savedata[0] 156 | graphdata = savedata[1] 157 | if dispWaterfall == True: 158 | imdata2 = savedata[2] 159 | cv2.imwrite("waterfall-" + now + ".png",imdata2) 160 | cv2.imwrite("spectrum-" + now + ".png",imdata1) 161 | #print(graphdata[0]) #wavelengths 162 | #print(graphdata[1]) #intensities 163 | f = open("Spectrum-"+now+'.csv','w') 164 | f.write('Wavelength,Intensity\r\n') 165 | for x in zip(graphdata[0],graphdata[1]): 166 | f.write(str(x[0])+','+str(x[1])+'\r\n') 167 | f.close() 168 | message = "Last Save: "+timenow 169 | return(message) 170 | 171 | while(cap.isOpened()): 172 | # Capture frame-by-frame 173 | ret, frame = cap.read() 174 | 175 | if ret == True: 176 | y=int((frameHeight/2)-40) #origin of the vertical crop 177 | #y=200 #origin of the vert crop 178 | x=0 #origin of the horiz crop 179 | h=80 #height of the crop 180 | w=frameWidth #width of the crop 181 | cropped = frame[y:y+h, x:x+w] 182 | bwimage = cv2.cvtColor(cropped,cv2.COLOR_BGR2GRAY) 183 | rows,cols = bwimage.shape 184 | halfway =int(rows/2) 185 | #show our line on the original image 186 | #now a 3px wide region 187 | cv2.line(cropped,(0,halfway-2),(frameWidth,halfway-2),(255,255,255),1) 188 | cv2.line(cropped,(0,halfway+2),(frameWidth,halfway+2),(255,255,255),1) 189 | 190 | #banner image 191 | decoded_data = base64.b64decode(background) 192 | np_data = np.frombuffer(decoded_data,np.uint8) 193 | img = cv2.imdecode(np_data,3) 194 | messages = img 195 | 196 | #blank image for Graph 197 | graph = np.zeros([320,frameWidth,3],dtype=np.uint8) 198 | graph.fill(255) #fill white 199 | 200 | #Display a graticule calibrated with cal data 201 | textoffset = 12 202 | #vertial lines every whole 10nm 203 | for position in tens: 204 | cv2.line(graph,(position,15),(position,320),(200,200,200),1) 205 | 206 | #vertical lines every whole 50nm 207 | for positiondata in fifties: 208 | cv2.line(graph,(positiondata[0],15),(positiondata[0],320),(0,0,0),1) 209 | cv2.putText(graph,str(positiondata[1])+'nm',(positiondata[0]-textoffset,12),font,0.4,(0,0,0),1, cv2.LINE_AA) 210 | 211 | #horizontal lines 212 | for i in range (320): 213 | if i>=64: 214 | if i%64==0: #suppress the first line then draw the rest... 215 | cv2.line(graph,(0,i),(frameWidth,i),(100,100,100),1) 216 | 217 | #Now process the intensity data and display it 218 | #intensity = [] 219 | for i in range(cols): 220 | #data = bwimage[halfway,i] #pull the pixel data from the halfway mark 221 | #print(type(data)) #numpy.uint8 222 | #average the data of 3 rows of pixels: 223 | dataminus1 = bwimage[halfway-1,i] 224 | datazero = bwimage[halfway,i] #pull the pixel data from the halfway mark 225 | dataplus1 = bwimage[halfway+1,i] 226 | data = (int(dataminus1)+int(datazero)+int(dataplus1))/3 227 | data = np.uint8(data) 228 | 229 | 230 | if holdpeaks == True: 231 | if data > intensity[i]: 232 | intensity[i] = data 233 | else: 234 | intensity[i] = data 235 | 236 | if dispWaterfall == True: 237 | #waterfall.... 238 | #data is smoothed at this point!!!!!! 239 | #create an empty array for the data 240 | wdata = np.zeros([1,frameWidth,3],dtype=np.uint8) 241 | index=0 242 | for i in intensity: 243 | rgb = wavelength_to_rgb(round(wavelengthData[index]))#derive the color from the wavelenthData array 244 | luminosity = intensity[index]/255 245 | b = int(round(rgb[0]*luminosity)) 246 | g = int(round(rgb[1]*luminosity)) 247 | r = int(round(rgb[2]*luminosity)) 248 | #print(b,g,r) 249 | #wdata[0,index]=(r,g,b) #fix me!!! how do we deal with this data?? 250 | wdata[0,index]=(r,g,b) 251 | index+=1 252 | waterfall = np.insert(waterfall, 0, wdata, axis=0) #insert line to beginning of array 253 | waterfall = waterfall[:-1].copy() #remove last element from array 254 | 255 | hsv = cv2.cvtColor(waterfall, cv2.COLOR_BGR2HSV) 256 | 257 | 258 | 259 | #Draw the intensity data :-) 260 | #first filter if not holding peaks! 261 | 262 | if holdpeaks == False: 263 | intensity = savitzky_golay(intensity,17,savpoly) 264 | intensity = np.array(intensity) 265 | intensity = intensity.astype(int) 266 | holdmsg = "Holdpeaks OFF" 267 | else: 268 | holdmsg = "Holdpeaks ON" 269 | 270 | 271 | #now draw the intensity data.... 272 | index=0 273 | for i in intensity: 274 | rgb = wavelength_to_rgb(round(wavelengthData[index]))#derive the color from the wvalenthData array 275 | r = rgb[0] 276 | g = rgb[1] 277 | b = rgb[2] 278 | #or some reason origin is top left. 279 | cv2.line(graph, (index,320), (index,320-i), (b,g,r), 1) 280 | cv2.line(graph, (index,319-i), (index,320-i), (0,0,0), 1,cv2.LINE_AA) 281 | index+=1 282 | 283 | 284 | #find peaks and label them 285 | textoffset = 12 286 | thresh = int(thresh) #make sure the data is int. 287 | indexes = peakIndexes(intensity, thres=thresh/max(intensity), min_dist=mindist) 288 | #print(indexes) 289 | for i in indexes: 290 | height = intensity[i] 291 | height = 310-height 292 | wavelength = round(wavelengthData[i],1) 293 | cv2.rectangle(graph,((i-textoffset)-2,height),((i-textoffset)+60,height-15),(0,255,255),-1) 294 | cv2.rectangle(graph,((i-textoffset)-2,height),((i-textoffset)+60,height-15),(0,0,0),1) 295 | cv2.putText(graph,str(wavelength)+'nm',(i-textoffset,height-3),font,0.4,(0,0,0),1, cv2.LINE_AA) 296 | #flagpoles 297 | cv2.line(graph,(i,height),(i,height+10),(0,0,0),1) 298 | 299 | 300 | if measure == True: 301 | #show the cursor! 302 | cv2.line(graph,(cursorX,cursorY-140),(cursorX,cursorY-180),(0,0,0),1) 303 | cv2.line(graph,(cursorX-20,cursorY-160),(cursorX+20,cursorY-160),(0,0,0),1) 304 | cv2.putText(graph,str(round(wavelengthData[cursorX],2))+'nm',(cursorX+5,cursorY-165),font,0.4,(0,0,0),1, cv2.LINE_AA) 305 | 306 | if recPixels == True: 307 | #display the points 308 | cv2.line(graph,(cursorX,cursorY-140),(cursorX,cursorY-180),(0,0,0),1) 309 | cv2.line(graph,(cursorX-20,cursorY-160),(cursorX+20,cursorY-160),(0,0,0),1) 310 | cv2.putText(graph,str(cursorX)+'px',(cursorX+5,cursorY-165),font,0.4,(0,0,0),1, cv2.LINE_AA) 311 | else: 312 | #also make sure the click array stays empty 313 | clickArray = [] 314 | 315 | if clickArray: 316 | for data in clickArray: 317 | mouseX=data[0] 318 | mouseY=data[1] 319 | cv2.circle(graph,(mouseX,mouseY),5,(0,0,0),-1) 320 | #we can display text :-) so we can work out wavelength from x-pos and display it ultimately 321 | cv2.putText(graph,str(mouseX),(mouseX+5,mouseY),cv2.FONT_HERSHEY_SIMPLEX,0.4,(0,0,0)) 322 | 323 | 324 | 325 | 326 | #stack the images and display the spectrum 327 | spectrum_vertical = np.vstack((messages,cropped, graph)) 328 | #dividing lines... 329 | cv2.line(spectrum_vertical,(0,80),(frameWidth,80),(255,255,255),1) 330 | cv2.line(spectrum_vertical,(0,160),(frameWidth,160),(255,255,255),1) 331 | #print the messages 332 | cv2.putText(spectrum_vertical,calmsg1,(490,15),font,0.4,(0,255,255),1, cv2.LINE_AA) 333 | cv2.putText(spectrum_vertical,calmsg3,(490,33),font,0.4,(0,255,255),1, cv2.LINE_AA) 334 | cv2.putText(spectrum_vertical,"Framerate: "+str(cfps),(490,51),font,0.4,(0,255,255),1, cv2.LINE_AA) 335 | cv2.putText(spectrum_vertical,saveMsg,(490,69),font,0.4,(0,255,255),1, cv2.LINE_AA) 336 | #Second column 337 | cv2.putText(spectrum_vertical,holdmsg,(640,15),font,0.4,(0,255,255),1, cv2.LINE_AA) 338 | cv2.putText(spectrum_vertical,"Savgol Filter: "+str(savpoly),(640,33),font,0.4,(0,255,255),1, cv2.LINE_AA) 339 | cv2.putText(spectrum_vertical,"Label Peak Width: "+str(mindist),(640,51),font,0.4,(0,255,255),1, cv2.LINE_AA) 340 | cv2.putText(spectrum_vertical,"Label Threshold: "+str(thresh),(640,69),font,0.4,(0,255,255),1, cv2.LINE_AA) 341 | cv2.imshow(title1,spectrum_vertical) 342 | 343 | if dispWaterfall == True: 344 | #stack the images and display the waterfall 345 | waterfall_vertical = np.vstack((messages,cropped, waterfall)) 346 | #dividing lines... 347 | cv2.line(waterfall_vertical,(0,80),(frameWidth,80),(255,255,255),1) 348 | cv2.line(waterfall_vertical,(0,160),(frameWidth,160),(255,255,255),1) 349 | #Draw this stuff over the top of the image! 350 | #Display a graticule calibrated with cal data 351 | textoffset = 12 352 | 353 | #vertical lines every whole 50nm 354 | for positiondata in fifties: 355 | for i in range(162,480): 356 | if i%20 == 0: 357 | cv2.line(waterfall_vertical,(positiondata[0],i),(positiondata[0],i+1),(0,0,0),2) 358 | cv2.line(waterfall_vertical,(positiondata[0],i),(positiondata[0],i+1),(255,255,255),1) 359 | cv2.putText(waterfall_vertical,str(positiondata[1])+'nm',(positiondata[0]-textoffset,475),font,0.4,(0,0,0),2, cv2.LINE_AA) 360 | cv2.putText(waterfall_vertical,str(positiondata[1])+'nm',(positiondata[0]-textoffset,475),font,0.4,(255,255,255),1, cv2.LINE_AA) 361 | 362 | cv2.putText(waterfall_vertical,calmsg1,(490,15),font,0.4,(0,255,255),1, cv2.LINE_AA) 363 | cv2.putText(waterfall_vertical,calmsg2,(490,33),font,0.4,(0,255,255),1, cv2.LINE_AA) 364 | cv2.putText(waterfall_vertical,calmsg3,(490,51),font,0.4,(0,255,255),1, cv2.LINE_AA) 365 | cv2.putText(waterfall_vertical,saveMsg,(490,69),font,0.4,(0,255,255),1, cv2.LINE_AA) 366 | 367 | cv2.putText(waterfall_vertical,holdmsg,(640,15),font,0.4,(0,255,255),1, cv2.LINE_AA) 368 | 369 | cv2.imshow(title2,waterfall_vertical) 370 | 371 | 372 | keyPress = cv2.waitKey(1) 373 | if keyPress == ord('q'): 374 | break 375 | elif keyPress == ord('h'): 376 | if holdpeaks == False: 377 | holdpeaks = True 378 | elif holdpeaks == True: 379 | holdpeaks = False 380 | elif keyPress == ord("s"): 381 | #package up the data! 382 | graphdata = [] 383 | graphdata.append(wavelengthData) 384 | graphdata.append(intensity) 385 | if dispWaterfall == True: 386 | savedata = [] 387 | savedata.append(spectrum_vertical) 388 | savedata.append(graphdata) 389 | savedata.append(waterfall_vertical) 390 | else: 391 | savedata = [] 392 | savedata.append(spectrum_vertical) 393 | savedata.append(graphdata) 394 | saveMsg = snapshot(savedata) 395 | elif keyPress == ord("c"): 396 | calcomplete = writecal(clickArray) 397 | if calcomplete: 398 | #overwrite wavelength data 399 | #Go grab the computed calibration data 400 | caldata = readcal(frameWidth) 401 | wavelengthData = caldata[0] 402 | calmsg1 = caldata[1] 403 | calmsg2 = caldata[2] 404 | calmsg3 = caldata[3] 405 | #overwrite graticule data 406 | graticuleData = generateGraticule(wavelengthData) 407 | tens = (graticuleData[0]) 408 | fifties = (graticuleData[1]) 409 | elif keyPress == ord("x"): 410 | clickArray = [] 411 | elif keyPress == ord("m"): 412 | recPixels = False #turn off recpixels! 413 | if measure == False: 414 | measure = True 415 | elif measure == True: 416 | measure = False 417 | elif keyPress == ord("p"): 418 | measure = False #turn off measure! 419 | if recPixels == False: 420 | recPixels = True 421 | elif recPixels == True: 422 | recPixels = False 423 | elif keyPress == ord("o"):#sav up 424 | savpoly+=1 425 | if savpoly >=15: 426 | savpoly=15 427 | elif keyPress == ord("l"):#sav down 428 | savpoly-=1 429 | if savpoly <=0: 430 | savpoly=0 431 | elif keyPress == ord("i"):#Peak width up 432 | mindist+=1 433 | if mindist >=100: 434 | mindist=100 435 | elif keyPress == ord("k"):#Peak Width down 436 | mindist-=1 437 | if mindist <=0: 438 | mindist=0 439 | elif keyPress == ord("u"):#label thresh up 440 | thresh+=1 441 | if thresh >=100: 442 | thresh=100 443 | elif keyPress == ord("j"):#label thresh down 444 | thresh-=1 445 | if thresh <=0: 446 | thresh=0 447 | else: 448 | break 449 | 450 | 451 | #Everything done, release the vid 452 | cap.release() 453 | 454 | cv2.destroyAllWindows() 455 | 456 | 457 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PySpectrometer2 2 | 3 | The second incarnation of the Spectrometer project! 4 | 5 | This is a more advanced, but more flexible version of the original program. It changes the spectrometer from educational 'toy' to serious instrument, which can easily compete with commercial units costing thousands of dollars! 6 | 7 | This program, hardware design, and associated information is Open Source (see Licence), but if you have gotten value from these kinds of projects and think they are worth something, please consider donating: https://paypal.me/leslaboratory?locale.x=en_GB 8 | This project is a follow on from: https://github.com/leswright1977/PySpectrometer 9 | 10 | This readme is accompanied by youtube videos, showing how to build and use the spectrometer! 11 | Visit my Youtube Channel at: https://www.youtube.com/leslaboratory 12 | 13 | There is a video on this project here: https://youtu.be/SCp9T8NKfnM 14 | 15 | ## Rationale for the new build 16 | 17 | Recent changes in the OS (Bullseye) broke the old version e.g broken video, broken dependencies and so on. PySpectrometer v3.0 was hacked and fixed as of 3.1), however I have been thinking about a rewrite of this software for a while, so here it is! 18 | 19 | Accuracy has been significantly improved by implementing multi-wavelength calibration and a Polynomial regression data fit to compute wavelengths as precisely as possible across the measured range. 20 | 21 | Tk has been dropped as the GUI to allow easier maintainability, extendability and flexibility. The new interface is coded entirely in OpenCV, and whilst things like trackbars and buttons are now dropped in favour of keybindings, this frees up screen real estate, and allows the support of resizing without fuss, and fullscreen mode is now supported! 22 | 23 | In Fullscreen mode on Raspberry Pi systems, the interface is designed to fit 800 x 480 screens, which seem to be a common resolution for RPi LCD's, paving the way for the creation of a stand alone benchtop instrument. 24 | 25 | ![Screenshot](media/calibrated.png) 26 | 27 | 28 | ### Whats new: 29 | 30 | - Higher resolution (800px wide graph). 31 | 32 | - 3 row pixel averaging of sensor data. 33 | 34 | - Fullscreen option for the Spectrometer graph. 35 | 36 | - 3rd order polynomial fit of calibration data for accurate measurement. 37 | 38 | - Improved graph labelling. 39 | 40 | - Labelled measurement cursors. 41 | 42 | - Optional waterfall display for recording spectra changes over time. 43 | 44 | - Key Bindings for all operations. 45 | 46 | - Analogue Gain control for the Picam 47 | 48 | 49 | The functionality of the previous version has been retained, including peak hold, peak detect, Savitsky Golay filter, and the ability to save graphs as png and data as CSV. 50 | 51 | A very cool addition to this project is a Waterfall display! This allows the recording of change in in wavelength over time. 52 | For my purposes this is especially useful for Dye Lasers, however this may be of use to those observing the spectra of light sources that may change in intensity or wavelength. 53 | 54 | Example waterfall display of a fluorescent lamp: 55 | ![Screenshot](media/waterfall-20221013--210412.png) 56 | 57 | 58 | Waterfall display of a Dye laser being tuned (Coumarin-1). The jagged tuning curve is because this laser was hand-tuned! 59 | ![Screenshot](media/waterfall-20221013--205708.png) 60 | 61 | Below the tuning curve of Rhodamine 6G 62 | ![Screenshot](media/waterfall-20221013--205840.png) 63 | 64 | 65 | 66 | 67 | # Hardware 68 | 69 | The hardware is simple and widely available and so should be easily to duplicate without critical alignment or difficult construction. The hard work was developing the software. 70 | 71 | Resolution/accuracy seems to be down to the nanometre with a well built and calibrated setup, which is excellent for the price of the hardware, especially when you consider the price of commercial components such as the Hamamatsu C12880MA breakout boards which run north of 300 bucks, and has a resolution of 15nm. Of course, this build is physically much larger, but not enormous! 72 | 73 | 74 | 75 | ## Standard Spectroscope 76 | 77 | For the standard build, I used a pocket spectroscope(link below) coupled into a picamera by means of a zoom lens. 78 | The job is simple: Mount the zoom lens on the picam, and mount the spectroscope in front, and focus the camera on the spectrum, until it is sharp and clear. Use either daylight (which has pronounced Fraunhoffer lines) or a fluorescent lamp, which has pronounced emission lines. The following command will help you: **libcamera-hello -t 0** 79 | 80 | ![Screenshot](media/scope.png) 81 | 82 | The hardware consists of: 83 | 84 | - A commercial Diffraction grating Spectroscope https://www.patonhawksley.com/product-page/benchtop-spectroscope 85 | 86 | - A Raspberry Pi Camera (with an M12 Thread) https://thepihut.com/products/raspberry-pi-camera-adjustable-focus-5mp 87 | 88 | - A CCTV Lens with Zoom (M12 Thread) (Search eBay for F1.6 zoom lens) 89 | 90 | Everything is assembled on an aluminium base (note the Camera is not cooled, the heatsink was a conveniently sized piece of aluminium.) 91 | 92 | ![Screenshot](media/parts.png) 93 | 94 | ![Screenshot](media/pi.png) 95 | 96 | ## Miniture Spectroscope 97 | 98 | The build is as simple as the standard version, however, uses a miniature pocket spectrometer. 99 | 100 | ![Screenshot](media/minispect.png) 101 | 102 | - A commercial Diffraction grating Pocket Spectroscope: https://www.patonhawksley.com/product-page/pocket-spectroscope 103 | 104 | - A Raspberry Pi Camera (with an M12 Thread): https://thepihut.com/products/raspberry-pi-camera-adjustable-focus-5mp 105 | 106 | - M12x0.5 F2.0 Fixed 12mm Focal length Lens: (search eBay) 107 | 108 | 109 | 110 | 111 | ## Stand alone unit 112 | 113 | ![Screenshot](media/mini.png) 114 | 115 | Above, a compact unit built with a Hyperpixel 4 inch screen, running on fullscreen mode (800 x 480) 116 | 117 | https://shop.pimoroni.com/products/hyperpixel-4?variant=12569539706963 118 | 119 | ## Custom units 120 | 121 | There is nothing to stop you building a spectrometer head with a couple of razor blades, a diffraction grating, a couple of lenses and a Picam! The software should work just the same as shown in this readme! 122 | 123 | 124 | 125 | 126 | # User Guide 127 | 128 | ## Key Bindings: 129 | 130 | ### Graph Display Controls 131 | * t/g = Analogue Gain up/down (not available on USB version, see below for alternative camera controls) 132 | * o/l = savpoly up/down 133 | * i/k = peak width up/down 134 | * u/j = Label threshold up/down 135 | * h = hold peaks 136 | 137 | ### Calibration and General Software 138 | * m = measure (Toggles measure function. In this mode a crosshairs is displayed on the Spectrogram that allows the measurement of wavelength) 139 | * p = record pixels (Toggles pixel function (Part of the calibration procedure) allows the selection of multiple pixel positions on the graph) 140 | * x = clear points (Clear selected pixel points above) 141 | * c = calibrate (Enter the calibration routine, requires console input) 142 | * s = save data (Saves Spectrograph as png and CSV data. Saves waterfall as png. 143 | * q = quit (Quit Program) 144 | 145 | ## Starting the program 146 | 147 | First, clone this repo! 148 | 149 | In /src you will find: 150 | 151 | * PySpectrometer2-Picam2-v1.0.py (PySpectrometer for Raspberry Pi) 152 | * PySpectrometer2-USB-v1.0.py (USB version of this program (This is for USB Cameras See end of Readme)). 153 | * specFunctions.py (A library of functions including: Wavelength to RGB, SavGol filter from Scipy, Peak detect from peakutils, readcal and writecal. 154 | 155 | ## Dependencies 156 | 157 | Run: **sudo apt-get install python3-opencv** 158 | 159 | **Also note, this build is designed for Raspberry Pi OS Bullseye, and will only work with the new libcamera based python library (picamera2)** 160 | It will **not** work with older versions of Raspbery Pi OS. You **will** however be able to use PySpectrometer2-USB-v1.0.py with an external USB camera with other Operating Systems. 161 | 162 | 163 | To run the program, first make it executable by running: **chmod +x PySpectrometer2-Picam2-v1.0.py** 164 | 165 | Run by typing: **./PySpectrometer2-Picam2-v1.0.py** 166 | 167 | Note to also display the waterfall display, run with the option: **./PySpectrometer2-Picam2-v1.0.py --waterfall** 168 | 169 | To run in fullscreen mode (perform calibration in standard mode first), run with the option: **./PySpectrometer2-Picam2-v1.0.py --fullscreen** 170 | 171 | When first started, the spectrometer is in an uncalibrated state! You must therefore perform the calibration procedure, but at this stage you should be able to focus and align your camera with your spectroscope using the preview window. Is is expected that red light is on the right, and blue-violet on the left. 172 | An excellent choice for this is daylight, as well defined Fraunhoffer lines are indicative of good camera focus. 173 | 174 | ## Calibration 175 | 176 | This version of the PySpectrometer performs Polynomial curve fitting of the user provided calibration wavelengths. This procedure if done with care with result in a precision instrument! 177 | 178 | When light from a diffraction grating falls upon a flat sensor the dispersion of light is not linear, and so calibration with just two data points (as in the old version of this software) will result in inaccurate readings. This nonlinearity is likely compounded by additional nonlinearities introduced by the camera lenses. To address the nonlinearity, the user must provide the pixel positions of at least 3 known wavelengths (4 to 6 is highly recommended for high accuracy!). This information is then used by the program to compute the wavelengths of every single pixel position of the sensor. 179 | 180 | Where 3 wavelengths are used for calibration, the software will perform a 2nd order polynomial fit (Reasonably accurate) 181 | 182 | Where 4 or more wavelengths are used, the software will perform a 3rd order polynomial fit (Very accurate) 183 | 184 | Assuming your physical spectrometer setup is rigid and robust (not held together with gravity, tape or hot glue!), calibration will only need to be done once (Data is saved to a file called: caldata.txt), and therafter when any change is made to the physical setup. 185 | 186 | Direct your Spectrometer at a light source with many discrete emission lines. A target illuminated by Lasers would be an excellent (though very expensive!) choice! An inexpensive alternative is a Fluorescent tube. 187 | 188 | You should be able to identify several peaks in your graph, now you need to match them up with known good data. For serious work I would recommend an academic resource such as: https://physics.nist.gov/PhysRefData/Handbook/Tables/mercurytable2.htm however in the spirit of citizen science (and because fluorescent lamps are somewhat variable in manufacture), I would recommend this wikipedia article to get you started: https://en.wikipedia.org/wiki/Fluorescent_lamp have a read, and scroll down to the section called: Phosphor composition. In here you will find emission spectra of a variety of fluorescent lamps! 189 | 190 | Likely the most useful is this graph: https://commons.wikimedia.org/wiki/File:Fluorescent_lighting_spectrum_peaks_labeled_with_colored_peaks_added.png 191 | 192 | These are the notable visible peaks: 193 | * 1 405.4 nm (Mercury) 194 | * 2 436.6 nm (Mercury) 195 | * 3 487.7 (Terbium) 196 | * 4 542.4 (Terbium) 197 | * 5 546.5 (Mercury) 198 | * 12 611.6 (Europium) 199 | * 14 631.1 (Europium) 200 | 201 | Once you have identified some peaks, at least 3, but even better 4 to 6, first press 'h' to toggle on peak hold, this will stabilize the graph, and even allow you to switch off the light source! 202 | 203 | Press the 'p' key on the keyboard. This will toggle on the pixel measuring crosshairs, move the crosshairs to each of your peaks, and click once the crosshairs are aligned with the 'flagpole' of the wavelength marker. 204 | Rinse and repeat for your identified peaks. (Note it makes sense to do this from left to right!) 205 | 206 | ![Screenshot](media/pointsadded.png) 207 | 208 | Once you have selected all of your peaks, press 'c' and turn your attention to the terminal window. 209 | For each pixel number, enter the identified wavelength. 210 | 211 | ![Screenshot](media/console.png) 212 | 213 | Once you have entered the wavelengths for each data point, the software will recalibrate the graticule and its internal representation of all the wavelength data. 214 | In the console, it will print out the value of R-Squared. This value will give an indication of how well the calculated data matches your input data. The closer this value is to 1, the more accurately you recorded your wavelengths! for example a six nines fit (0.999999xxxx) is excellent, and 5 nines is good. If it is a way off, one or more of your identified wavelengths may be incorrect, and you should repeat the calibaration procedure! (Press 'x' to clear the points, and repeat the calibration procedure) 215 | 216 | ### Check your work 217 | Refer back to the graph from the wiki, can you identify with a reasonable degree of accuracy other peaks? (bearing in mind your fluorescent lamp may differ from the one on the wiki!). 218 | 219 | ![Screenshot](media/calibrated.png) 220 | 221 | In the screenshot above above, a well defined peak (not used as a caibration value) at 587.4nm has been detected. Referring to the Wiki this is listed at 587.6nm, only 0.2nm off with a five nines calibration! :-) 222 | For unlabelled peaks, pressing 'm' will toggle on the measurement crosshairs, that display wavelength for any given position. 223 | 224 | Calibration data is written to a file (caldata.txt), so calibration is retained when the program is restarted. 225 | 226 | # Saving Data 227 | 228 | Pressing 's' will save all data. It saves graph and waterfall data as PNG images, with date and time as part of the filename. 229 | Additionally it saves graph data as CSV that can then be opened in other programs such as OpenOffice on the Pi. 230 | 231 | ![Screenshot](media/csv.png) 232 | 233 | # Arbitrary measurement 234 | 235 | Pressing the 'm' key will toggle a measurement cursor. This can be used (once the intrument has been calibrated) to arbitratily measure any point on the graph. The following screenshot shows the measurement of a possible Terbium or Mercury line at 577nm 236 | 237 | ![Screenshot](media/terbium-measure.png) 238 | 239 | Below: Another example of measurement cursors on a weak line at 437nm. This is from a high pressure Sodium lamp as is in fact likely Mercury (HP Sodium lamps contain Xenon, Mecury and Sodium). 240 | 241 | The prominent peak at 589nm is Sodium. 242 | Of the other peaks: 546.6nm is Mercury, 568.9nm is Sodium, 577nm and 579nm (unlabelled, but just to the left of the 589nm Sodium peak) are both Mercury. 243 | 244 | ![Screenshot](media/high-pressure-sodium.png) 245 | 246 | # USB Camera Version 247 | 248 | A version of the software is provided for those who wish to use third party USB cameras with the Pi, or even a USB camera with any other Linux box! 249 | 250 | The following command line options must be considered: 251 | 252 | - Video device number 253 | - Framerate 254 | 255 | **Note: the expected resolution from USB cameras is 800x600, other resolutions will cause the software to crash!** 256 | 257 | For an external USB camera, first find the device by issuing: 258 | **v4l2-ctl --list-devices** 259 | 260 | Once you have determined this, you can run the program. For example if your camera is /dev/video3 and you require a framerate of 15fps you would issue: 261 | 262 | **./PySpectrometer2-USB-v1.0.py --device 3 --fps 15** 263 | 264 | If you want fine control over camera settings use guvcview: **sudo apt-get install guvcview** 265 | 266 | You can run guvcview at the same time as the spectrometer software, so long as you disable guvcview preview, like this: 267 | 268 | (assuming your device is /dev/video3) 269 | 270 | **guvcview --device /dev/video3 --control_panel** 271 | 272 | This will allow you to control: 273 | - Brightness 274 | - Contrast 275 | - Saturation 276 | - Gain 277 | - Other settings (depending on camera, see note below) 278 | 279 | Note: Guvcview is a more sensible choice for camera control, than trying to shoehorn in USB camera functionality into this code. The Python OpenCV libary has limited and oftentimes broken support for the huge variety of USB cameras out there, and so direct control with a tried and tested utility makes sense! 280 | 281 | 282 | 283 | 284 | # Future work: 285 | 286 | It is planned to add inputs of some description, to allow the use of buttons and knobs to control the Spectrometer. 287 | 288 | The type of inputs will depend on oddly the type of screen! 289 | 290 | The hyperpixel displays consume all of the GPIO on the Pi, however buttons could easily be provided if they talk HID. 291 | 292 | DSI displays could be used, however seemingly that might require the user roll back to legacy camera support! 293 | 294 | HDMI displays can be used, and this would free up all the GPIO. 295 | 296 | A one size fits all approach would be a HID device, there is plenty of choice, including using a Teensy or an Arduino and buttons, or even a number pad with custom keycaps. 297 | 298 | 299 | 300 | I am thinking of implementing something approaching autocalibration, though this might be difficult implement for all use-cases. 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | -------------------------------------------------------------------------------- /src/PySpectrometer2-Picam2-v1.0.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | ''' 4 | PySpectrometer2 Les Wright 2022 5 | https://www.youtube.com/leslaboratory 6 | https://github.com/leswright1977 7 | 8 | This project is a follow on from: https://github.com/leswright1977/PySpectrometer 9 | 10 | This is a more advanced, but more flexible version of the original program. Tk Has been dropped as the GUI to allow fullscreen mode on Raspberry Pi systems and the iterface is designed to fit 800*480 screens, which seem to be a common resolutin for RPi LCD's, paving the way for the creation of a stand alone benchtop instrument. 11 | 12 | Whats new: 13 | Higher resolution (800px wide graph) 14 | 3 row pixel averaging of sensor data 15 | Fullscreen option for the Spectrometer graph 16 | 3rd order polymonial fit of calibration data for accurate measurement. 17 | Improved graph labelling 18 | Labelled measurement cursors 19 | Optional waterfall display for recording spectra changes over time. 20 | Key Bindings for all operations 21 | 22 | All old features have been kept, including peak hold, peak detect, Savitsky Golay filter, and the ability to save graphs as png and data as CSV. 23 | 24 | For instructions please consult the readme! 25 | ''' 26 | 27 | 28 | import cv2 29 | import time 30 | import numpy as np 31 | from specFunctions import wavelength_to_rgb,savitzky_golay,peakIndexes,readcal,writecal,background,generateGraticule 32 | import base64 33 | import argparse 34 | from picamera2 import Picamera2 35 | 36 | parser = argparse.ArgumentParser() 37 | group = parser.add_mutually_exclusive_group() 38 | group.add_argument("--fullscreen", help="Fullscreen (Native 800*480)",action="store_true") 39 | group.add_argument("--waterfall", help="Enable Waterfall (Windowed only)",action="store_true") 40 | args = parser.parse_args() 41 | dispFullscreen = False 42 | dispWaterfall = False 43 | if args.fullscreen: 44 | print("Fullscreen Spectrometer enabled") 45 | dispFullscreen = True 46 | if args.waterfall: 47 | print("Waterfall display enabled") 48 | dispWaterfall = True 49 | 50 | 51 | 52 | frameWidth = 800 53 | frameHeight = 600 54 | 55 | picam2 = Picamera2() 56 | #need to spend more time at: https://datasheets.raspberrypi.com/camera/picamera2-manual.pdf 57 | #but this will do for now! 58 | #min and max microseconds per frame gives framerate. 59 | #30fps (33333, 33333) 60 | #25fps (40000, 40000) 61 | 62 | picamGain = 10.0 63 | 64 | video_config = picam2.create_video_configuration(main={"format": 'RGB888', "size": (frameWidth, frameHeight)}, controls={"FrameDurationLimits": (33333, 33333)}) 65 | picam2.configure(video_config) 66 | picam2.start() 67 | 68 | #Change analog gain 69 | #picam2.set_controls({"AnalogueGain": 10.0}) #Default 1 70 | #picam2.set_controls({"Brightness": 0.2}) #Default 0 range -1.0 to +1.0 71 | #picam2.set_controls({"Contrast": 1.8}) #Default 1 range 0.0-32.0 72 | 73 | 74 | 75 | title1 = 'PySpectrometer 2 - Spectrograph' 76 | title2 = 'PySpectrometer 2 - Waterfall' 77 | stackHeight = 320+80+80 #height of the displayed CV window (graph+preview+messages) 78 | 79 | if dispWaterfall == True: 80 | #watefall first so spectrum is on top 81 | cv2.namedWindow(title2,cv2.WINDOW_GUI_NORMAL) 82 | cv2.resizeWindow(title2,frameWidth,stackHeight) 83 | cv2.moveWindow(title2,200,200); 84 | 85 | if dispFullscreen == True: 86 | cv2.namedWindow(title1,cv2.WND_PROP_FULLSCREEN) 87 | cv2.setWindowProperty(title1,cv2.WND_PROP_FULLSCREEN,cv2.WINDOW_FULLSCREEN) 88 | else: 89 | cv2.namedWindow(title1,cv2.WINDOW_GUI_NORMAL) 90 | cv2.resizeWindow(title1,frameWidth,stackHeight) 91 | cv2.moveWindow(title1,0,0); 92 | 93 | #settings for peak detect 94 | savpoly = 7 #savgol filter polynomial max val 15 95 | mindist = 50 #minumum distance between peaks max val 100 96 | thresh = 20 #Threshold max val 100 97 | 98 | calibrate = False 99 | 100 | clickArray = [] 101 | cursorX = 0 102 | cursorY = 0 103 | def handle_mouse(event,x,y,flags,param): 104 | global clickArray 105 | global cursorX 106 | global cursorY 107 | mouseYOffset = 160 108 | if event == cv2.EVENT_MOUSEMOVE: 109 | cursorX = x 110 | cursorY = y 111 | if event == cv2.EVENT_LBUTTONDOWN: 112 | mouseX = x 113 | mouseY = y-mouseYOffset 114 | clickArray.append([mouseX,mouseY]) 115 | #listen for click on plot window 116 | cv2.setMouseCallback(title1,handle_mouse) 117 | 118 | 119 | font=cv2.FONT_HERSHEY_SIMPLEX 120 | 121 | intensity = [0] * frameWidth #array for intensity data...full of zeroes 122 | 123 | holdpeaks = False #are we holding peaks? 124 | measure = False #are we measuring? 125 | recPixels = False #are we measuring pixels and recording clicks? 126 | 127 | 128 | #messages 129 | msg1 = "" 130 | saveMsg = "No data saved" 131 | 132 | #blank image for Waterfall 133 | waterfall = np.zeros([320,frameWidth,3],dtype=np.uint8) 134 | waterfall.fill(0) #fill black 135 | 136 | #Go grab the computed calibration data 137 | caldata = readcal(frameWidth) 138 | wavelengthData = caldata[0] 139 | calmsg1 = caldata[1] 140 | calmsg2 = caldata[2] 141 | calmsg3 = caldata[3] 142 | 143 | #generate the craticule data 144 | graticuleData = generateGraticule(wavelengthData) 145 | tens = (graticuleData[0]) 146 | fifties = (graticuleData[1]) 147 | 148 | def snapshot(savedata): 149 | now = time.strftime("%Y%m%d--%H%M%S") 150 | timenow = time.strftime("%H:%M:%S") 151 | imdata1 = savedata[0] 152 | graphdata = savedata[1] 153 | if dispWaterfall == True: 154 | imdata2 = savedata[2] 155 | cv2.imwrite("waterfall-" + now + ".png",imdata2) 156 | cv2.imwrite("spectrum-" + now + ".png",imdata1) 157 | #print(graphdata[0]) #wavelengths 158 | #print(graphdata[1]) #intensities 159 | f = open("Spectrum-"+now+'.csv','w') 160 | f.write('Wavelength,Intensity\r\n') 161 | for x in zip(graphdata[0],graphdata[1]): 162 | f.write(str(x[0])+','+str(x[1])+'\r\n') 163 | f.close() 164 | message = "Last Save: "+timenow 165 | return(message) 166 | 167 | 168 | while True: 169 | # Capture frame-by-frame 170 | frame = picam2.capture_array() 171 | y=int((frameHeight/2)-40) #origin of the vertical crop 172 | #y=200 #origin of the vert crop 173 | x=0 #origin of the horiz crop 174 | h=80 #height of the crop 175 | w=frameWidth #width of the crop 176 | cropped = frame[y:y+h, x:x+w] 177 | bwimage = cv2.cvtColor(cropped,cv2.COLOR_BGR2GRAY) 178 | rows,cols = bwimage.shape 179 | halfway =int(rows/2) 180 | #show our line on the original image 181 | #now a 3px wide region 182 | cv2.line(cropped,(0,halfway-2),(frameWidth,halfway-2),(255,255,255),1) 183 | cv2.line(cropped,(0,halfway+2),(frameWidth,halfway+2),(255,255,255),1) 184 | 185 | #banner image 186 | decoded_data = base64.b64decode(background) 187 | np_data = np.frombuffer(decoded_data,np.uint8) 188 | img = cv2.imdecode(np_data,3) 189 | messages = img 190 | 191 | #blank image for Graph 192 | graph = np.zeros([320,frameWidth,3],dtype=np.uint8) 193 | graph.fill(255) #fill white 194 | 195 | #Display a graticule calibrated with cal data 196 | textoffset = 12 197 | #vertial lines every whole 10nm 198 | for position in tens: 199 | cv2.line(graph,(position,15),(position,320),(200,200,200),1) 200 | 201 | #vertical lines every whole 50nm 202 | for positiondata in fifties: 203 | cv2.line(graph,(positiondata[0],15),(positiondata[0],320),(0,0,0),1) 204 | cv2.putText(graph,str(positiondata[1])+'nm',(positiondata[0]-textoffset,12),font,0.4,(0,0,0),1, cv2.LINE_AA) 205 | 206 | #horizontal lines 207 | for i in range (320): 208 | if i>=64: 209 | if i%64==0: #suppress the first line then draw the rest... 210 | cv2.line(graph,(0,i),(frameWidth,i),(100,100,100),1) 211 | 212 | #Now process the intensity data and display it 213 | #intensity = [] 214 | for i in range(cols): 215 | #data = bwimage[halfway,i] #pull the pixel data from the halfway mark 216 | #print(type(data)) #numpy.uint8 217 | #average the data of 3 rows of pixels: 218 | dataminus1 = bwimage[halfway-1,i] 219 | datazero = bwimage[halfway,i] #pull the pixel data from the halfway mark 220 | dataplus1 = bwimage[halfway+1,i] 221 | data = (int(dataminus1)+int(datazero)+int(dataplus1))/3 222 | data = np.uint8(data) 223 | 224 | 225 | if holdpeaks == True: 226 | if data > intensity[i]: 227 | intensity[i] = data 228 | else: 229 | intensity[i] = data 230 | 231 | if dispWaterfall == True: 232 | #waterfall.... 233 | #data is smoothed at this point!!!!!! 234 | #create an empty array for the data 235 | wdata = np.zeros([1,frameWidth,3],dtype=np.uint8) 236 | index=0 237 | for i in intensity: 238 | rgb = wavelength_to_rgb(round(wavelengthData[index]))#derive the color from the wavelenthData array 239 | luminosity = intensity[index]/255 240 | b = int(round(rgb[0]*luminosity)) 241 | g = int(round(rgb[1]*luminosity)) 242 | r = int(round(rgb[2]*luminosity)) 243 | #print(b,g,r) 244 | #wdata[0,index]=(r,g,b) #fix me!!! how do we deal with this data?? 245 | wdata[0,index]=(r,g,b) 246 | index+=1 247 | #bright and contrast of final image 248 | contrast = 2.5 249 | brightness =10 250 | wdata = cv2.addWeighted( wdata, contrast, wdata, 0, brightness) 251 | waterfall = np.insert(waterfall, 0, wdata, axis=0) #insert line to beginning of array 252 | waterfall = waterfall[:-1].copy() #remove last element from array 253 | 254 | hsv = cv2.cvtColor(waterfall, cv2.COLOR_BGR2HSV) 255 | 256 | 257 | #Draw the intensity data :-) 258 | #first filter if not holding peaks! 259 | 260 | if holdpeaks == False: 261 | intensity = savitzky_golay(intensity,17,savpoly) 262 | intensity = np.array(intensity) 263 | intensity = intensity.astype(int) 264 | holdmsg = "Holdpeaks OFF" 265 | else: 266 | holdmsg = "Holdpeaks ON" 267 | 268 | 269 | #now draw the intensity data.... 270 | index=0 271 | for i in intensity: 272 | rgb = wavelength_to_rgb(round(wavelengthData[index]))#derive the color from the wvalenthData array 273 | r = rgb[0] 274 | g = rgb[1] 275 | b = rgb[2] 276 | #or some reason origin is top left. 277 | cv2.line(graph, (index,320), (index,320-i), (b,g,r), 1) 278 | cv2.line(graph, (index,319-i), (index,320-i), (0,0,0), 1,cv2.LINE_AA) 279 | index+=1 280 | 281 | 282 | #find peaks and label them 283 | textoffset = 12 284 | thresh = int(thresh) #make sure the data is int. 285 | indexes = peakIndexes(intensity, thres=thresh/max(intensity), min_dist=mindist) 286 | #print(indexes) 287 | for i in indexes: 288 | height = intensity[i] 289 | height = 310-height 290 | wavelength = round(wavelengthData[i],1) 291 | cv2.rectangle(graph,((i-textoffset)-2,height),((i-textoffset)+60,height-15),(0,255,255),-1) 292 | cv2.rectangle(graph,((i-textoffset)-2,height),((i-textoffset)+60,height-15),(0,0,0),1) 293 | cv2.putText(graph,str(wavelength)+'nm',(i-textoffset,height-3),font,0.4,(0,0,0),1, cv2.LINE_AA) 294 | #flagpoles 295 | cv2.line(graph,(i,height),(i,height+10),(0,0,0),1) 296 | 297 | 298 | if measure == True: 299 | #show the cursor! 300 | cv2.line(graph,(cursorX,cursorY-140),(cursorX,cursorY-180),(0,0,0),1) 301 | cv2.line(graph,(cursorX-20,cursorY-160),(cursorX+20,cursorY-160),(0,0,0),1) 302 | cv2.putText(graph,str(round(wavelengthData[cursorX],2))+'nm',(cursorX+5,cursorY-165),font,0.4,(0,0,0),1, cv2.LINE_AA) 303 | 304 | if recPixels == True: 305 | #display the points 306 | cv2.line(graph,(cursorX,cursorY-140),(cursorX,cursorY-180),(0,0,0),1) 307 | cv2.line(graph,(cursorX-20,cursorY-160),(cursorX+20,cursorY-160),(0,0,0),1) 308 | cv2.putText(graph,str(cursorX)+'px',(cursorX+5,cursorY-165),font,0.4,(0,0,0),1, cv2.LINE_AA) 309 | else: 310 | #also make sure the click array stays empty 311 | clickArray = [] 312 | 313 | if clickArray: 314 | for data in clickArray: 315 | mouseX=data[0] 316 | mouseY=data[1] 317 | cv2.circle(graph,(mouseX,mouseY),5,(0,0,0),-1) 318 | #we can display text :-) so we can work out wavelength from x-pos and display it ultimately 319 | cv2.putText(graph,str(mouseX),(mouseX+5,mouseY),cv2.FONT_HERSHEY_SIMPLEX,0.4,(0,0,0)) 320 | 321 | 322 | 323 | 324 | #stack the images and display the spectrum 325 | spectrum_vertical = np.vstack((messages,cropped, graph)) 326 | #dividing lines... 327 | cv2.line(spectrum_vertical,(0,80),(frameWidth,80),(255,255,255),1) 328 | cv2.line(spectrum_vertical,(0,160),(frameWidth,160),(255,255,255),1) 329 | #print the messages 330 | cv2.putText(spectrum_vertical,calmsg1,(490,15),font,0.4,(0,255,255),1, cv2.LINE_AA) 331 | cv2.putText(spectrum_vertical,calmsg3,(490,33),font,0.4,(0,255,255),1, cv2.LINE_AA) 332 | cv2.putText(spectrum_vertical,saveMsg,(490,51),font,0.4,(0,255,255),1, cv2.LINE_AA) 333 | cv2.putText(spectrum_vertical,"Gain: "+str(picamGain),(490,69),font,0.4,(0,255,255),1, cv2.LINE_AA) 334 | #Second column 335 | cv2.putText(spectrum_vertical,holdmsg,(640,15),font,0.4,(0,255,255),1, cv2.LINE_AA) 336 | cv2.putText(spectrum_vertical,"Savgol Filter: "+str(savpoly),(640,33),font,0.4,(0,255,255),1, cv2.LINE_AA) 337 | cv2.putText(spectrum_vertical,"Label Peak Width: "+str(mindist),(640,51),font,0.4,(0,255,255),1, cv2.LINE_AA) 338 | cv2.putText(spectrum_vertical,"Label Threshold: "+str(thresh),(640,69),font,0.4,(0,255,255),1, cv2.LINE_AA) 339 | cv2.imshow(title1,spectrum_vertical) 340 | 341 | if dispWaterfall == True: 342 | #stack the images and display the waterfall 343 | waterfall_vertical = np.vstack((messages,cropped, waterfall)) 344 | #dividing lines... 345 | cv2.line(waterfall_vertical,(0,80),(frameWidth,80),(255,255,255),1) 346 | cv2.line(waterfall_vertical,(0,160),(frameWidth,160),(255,255,255),1) 347 | #Draw this stuff over the top of the image! 348 | #Display a graticule calibrated with cal data 349 | textoffset = 12 350 | 351 | #vertical lines every whole 50nm 352 | for positiondata in fifties: 353 | for i in range(162,480): 354 | if i%20 == 0: 355 | cv2.line(waterfall_vertical,(positiondata[0],i),(positiondata[0],i+1),(0,0,0),2) 356 | cv2.line(waterfall_vertical,(positiondata[0],i),(positiondata[0],i+1),(255,255,255),1) 357 | cv2.putText(waterfall_vertical,str(positiondata[1])+'nm',(positiondata[0]-textoffset,475),font,0.4,(0,0,0),2, cv2.LINE_AA) 358 | cv2.putText(waterfall_vertical,str(positiondata[1])+'nm',(positiondata[0]-textoffset,475),font,0.4,(255,255,255),1, cv2.LINE_AA) 359 | 360 | cv2.putText(waterfall_vertical,calmsg1,(490,15),font,0.4,(0,255,255),1, cv2.LINE_AA) 361 | cv2.putText(waterfall_vertical,calmsg3,(490,33),font,0.4,(0,255,255),1, cv2.LINE_AA) 362 | cv2.putText(waterfall_vertical,saveMsg,(490,51),font,0.4,(0,255,255),1, cv2.LINE_AA) 363 | cv2.putText(waterfall_vertical,"Gain: "+str(picamGain),(490,69),font,0.4,(0,255,255),1, cv2.LINE_AA) 364 | 365 | cv2.putText(waterfall_vertical,holdmsg,(640,15),font,0.4,(0,255,255),1, cv2.LINE_AA) 366 | 367 | cv2.imshow(title2,waterfall_vertical) 368 | 369 | 370 | keyPress = cv2.waitKey(1) 371 | if keyPress == ord('q'): 372 | break 373 | elif keyPress == ord('h'): 374 | if holdpeaks == False: 375 | holdpeaks = True 376 | elif holdpeaks == True: 377 | holdpeaks = False 378 | elif keyPress == ord("s"): 379 | #package up the data! 380 | graphdata = [] 381 | graphdata.append(wavelengthData) 382 | graphdata.append(intensity) 383 | if dispWaterfall == True: 384 | savedata = [] 385 | savedata.append(spectrum_vertical) 386 | savedata.append(graphdata) 387 | savedata.append(waterfall_vertical) 388 | else: 389 | savedata = [] 390 | savedata.append(spectrum_vertical) 391 | savedata.append(graphdata) 392 | saveMsg = snapshot(savedata) 393 | elif keyPress == ord("c"): 394 | calcomplete = writecal(clickArray) 395 | if calcomplete: 396 | #overwrite wavelength data 397 | #Go grab the computed calibration data 398 | caldata = readcal(frameWidth) 399 | wavelengthData = caldata[0] 400 | calmsg1 = caldata[1] 401 | calmsg2 = caldata[2] 402 | calmsg3 = caldata[3] 403 | #overwrite graticule data 404 | graticuleData = generateGraticule(wavelengthData) 405 | tens = (graticuleData[0]) 406 | fifties = (graticuleData[1]) 407 | elif keyPress == ord("x"): 408 | clickArray = [] 409 | elif keyPress == ord("m"): 410 | recPixels = False #turn off recpixels! 411 | if measure == False: 412 | measure = True 413 | elif measure == True: 414 | measure = False 415 | elif keyPress == ord("p"): 416 | measure = False #turn off measure! 417 | if recPixels == False: 418 | recPixels = True 419 | elif recPixels == True: 420 | recPixels = False 421 | elif keyPress == ord("o"):#sav up 422 | savpoly+=1 423 | if savpoly >=15: 424 | savpoly=15 425 | elif keyPress == ord("l"):#sav down 426 | savpoly-=1 427 | if savpoly <=0: 428 | savpoly=0 429 | elif keyPress == ord("i"):#Peak width up 430 | mindist+=1 431 | if mindist >=100: 432 | mindist=100 433 | elif keyPress == ord("k"):#Peak Width down 434 | mindist-=1 435 | if mindist <=0: 436 | mindist=0 437 | elif keyPress == ord("u"):#label thresh up 438 | thresh+=1 439 | if thresh >=100: 440 | thresh=100 441 | elif keyPress == ord("j"):#label thresh down 442 | thresh-=1 443 | if thresh <=0: 444 | thresh=0 445 | 446 | elif keyPress == ord("t"):#Gain up! 447 | picamGain += 1 448 | if picamGain >=50: 449 | picamGain = 50.0 450 | picam2.set_controls({"AnalogueGain": picamGain}) 451 | print("Camera Gain: "+str(picamGain)) 452 | elif keyPress == ord("g"):#Gain down 453 | picamGain -= 1 454 | if picamGain <=0: 455 | picamGain = 0.0 456 | picam2.set_controls({"AnalogueGain": picamGain}) 457 | print("Camera Gain: "+str(picamGain)) 458 | 459 | 460 | 461 | 462 | #Everything done 463 | cv2.destroyAllWindows() 464 | 465 | 466 | -------------------------------------------------------------------------------- /src/specFunctions.py: -------------------------------------------------------------------------------- 1 | ''' 2 | PySpectrometer2 Les Wright 2022 3 | https://www.youtube.com/leslaboratory 4 | https://github.com/leswright1977 5 | 6 | This project is a follow on from: https://github.com/leswright1977/PySpectrometer 7 | 8 | This is a more advanced, but more flexible version of the original program. Tk Has been dropped as the GUI to allow fullscreen mode on Raspberry Pi systems and the iterface is designed to fit 800*480 screens, which seem to be a common resolutin for RPi LCD's, paving the way for the creation of a stand alone benchtop instrument. 9 | 10 | Whats new: 11 | Higher reolution (800px wide graph) 12 | 3 row pixel averaging of sensor data 13 | Fullscreen option for the Spectrometer graph 14 | 3rd order polymonial fit of calibration data for accurate measurement. 15 | Improved graph labelling 16 | Labelled measurement cursors 17 | Optional waterfall display for recording spectra changes over time. 18 | Key Bindings for all operations 19 | 20 | All old features have been kept, including peak hold, peak detect, Savitsky Golay filter, and the ability to save graphs as png and data as CSV. 21 | 22 | For instructions please consult the readme! 23 | 24 | Future work: 25 | It is planned to add in GPIO support, to allow the use of buttons and knobs to control the Spectrometer. 26 | ''' 27 | 28 | 29 | import numpy as np 30 | import time 31 | 32 | def wavelength_to_rgb(nm): 33 | #from: Chris Webb https://www.codedrome.com/exploring-the-visible-spectrum-in-python/ 34 | #returns RGB vals for a given wavelength 35 | gamma = 0.8 36 | max_intensity = 255 37 | factor = 0 38 | rgb = {"R": 0, "G": 0, "B": 0} 39 | if 380 <= nm <= 439: 40 | rgb["R"] = -(nm - 440) / (440 - 380) 41 | rgb["G"] = 0.0 42 | rgb["B"] = 1.0 43 | elif 440 <= nm <= 489: 44 | rgb["R"] = 0.0 45 | rgb["G"] = (nm - 440) / (490 - 440) 46 | rgb["B"] = 1.0 47 | elif 490 <= nm <= 509: 48 | rgb["R"] = 0.0 49 | rgb["G"] = 1.0 50 | rgb["B"] = -(nm - 510) / (510 - 490) 51 | elif 510 <= nm <= 579: 52 | rgb["R"] = (nm - 510) / (580 - 510) 53 | rgb["G"] = 1.0 54 | rgb["B"] = 0.0 55 | elif 580 <= nm <= 644: 56 | rgb["R"] = 1.0 57 | rgb["G"] = -(nm - 645) / (645 - 580) 58 | rgb["B"] = 0.0 59 | elif 645 <= nm <= 780: 60 | rgb["R"] = 1.0 61 | rgb["G"] = 0.0 62 | rgb["B"] = 0.0 63 | if 380 <= nm <= 419: 64 | factor = 0.3 + 0.7 * (nm - 380) / (420 - 380) 65 | elif 420 <= nm <= 700: 66 | factor = 1.0 67 | elif 701 <= nm <= 780: 68 | factor = 0.3 + 0.7 * (780 - nm) / (780 - 700) 69 | if rgb["R"] > 0: 70 | rgb["R"] = int(max_intensity * ((rgb["R"] * factor) ** gamma)) 71 | else: 72 | rgb["R"] = 0 73 | if rgb["G"] > 0: 74 | rgb["G"] = int(max_intensity * ((rgb["G"] * factor) ** gamma)) 75 | else: 76 | rgb["G"] = 0 77 | if rgb["B"] > 0: 78 | rgb["B"] = int(max_intensity * ((rgb["B"] * factor) ** gamma)) 79 | else: 80 | rgb["B"] = 0 81 | #display no color as gray 82 | if(rgb["R"]+rgb["G"]+rgb["B"]) == 0: 83 | rgb["R"] = 155 84 | rgb["G"] = 155 85 | rgb["B"] = 155 86 | return (rgb["R"], rgb["G"], rgb["B"]) 87 | 88 | 89 | def savitzky_golay(y, window_size, order, deriv=0, rate=1): 90 | #scipy 91 | #From: https://scipy.github.io/old-wiki/pages/Cookbook/SavitzkyGolay 92 | ''' 93 | Copyright (c) 2001-2002 Enthought, Inc. 2003-2022, SciPy Developers. 94 | All rights reserved. 95 | 96 | Redistribution and use in source and binary forms, with or without 97 | modification, are permitted provided that the following conditions 98 | are met: 99 | 100 | 1. Redistributions of source code must retain the above copyright 101 | notice, this list of conditions and the following disclaimer. 102 | 103 | 2. Redistributions in binary form must reproduce the above 104 | copyright notice, this list of conditions and the following 105 | disclaimer in the documentation and/or other materials provided 106 | with the distribution. 107 | 108 | 3. Neither the name of the copyright holder nor the names of its 109 | contributors may be used to endorse or promote products derived 110 | from this software without specific prior written permission. 111 | 112 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 113 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 114 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 115 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 116 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 117 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 118 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 119 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 120 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 121 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 122 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 123 | ''' 124 | import numpy as np 125 | from math import factorial 126 | try: 127 | window_size = np.abs(np.int32(window_size)) 128 | order = np.abs(np.int32(order)) 129 | except ValueError: 130 | raise ValueError("window_size and order have to be of type int") 131 | if window_size % 2 != 1 or window_size < 1: 132 | raise TypeError("window_size size must be a positive odd number") 133 | if window_size < order + 2: 134 | raise TypeError("window_size is too small for the polynomials order") 135 | order_range = range(order+1) 136 | half_window = (window_size -1) // 2 137 | # precompute coefficients 138 | b = np.mat([[k**i for i in order_range] for k in range(-half_window, half_window+1)]) 139 | m = np.linalg.pinv(b).A[deriv] * rate**deriv * factorial(deriv) 140 | # pad the signal at the extremes with 141 | # values taken from the signal itself 142 | firstvals = y[0] - np.abs( y[1:half_window+1][::-1] - y[0] ) 143 | lastvals = y[-1] + np.abs(y[-half_window-1:-1][::-1] - y[-1]) 144 | y = np.concatenate((firstvals, y, lastvals)) 145 | return np.convolve( m[::-1], y, mode='valid') 146 | 147 | def peakIndexes(y, thres=0.3, min_dist=1, thres_abs=False): 148 | #from peakutils 149 | #from https://bitbucket.org/lucashnegri/peakutils/raw/f48d65a9b55f61fb65f368b75a2c53cbce132a0c/peakutils/peak.py 150 | ''' 151 | The MIT License (MIT) 152 | 153 | Copyright (c) 2014-2022 Lucas Hermann Negri 154 | 155 | Permission is hereby granted, free of charge, to any person obtaining a copy 156 | of this software and associated documentation files (the "Software"), to deal 157 | in the Software without restriction, including without limitation the rights 158 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 159 | copies of the Software, and to permit persons to whom the Software is 160 | furnished to do so, subject to the following conditions: 161 | 162 | The above copyright notice and this permission notice shall be included in 163 | all copies or substantial portions of the Software. 164 | 165 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 166 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 167 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 168 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 169 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 170 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 171 | THE SOFTWARE. 172 | ''' 173 | if isinstance(y, np.ndarray) and np.issubdtype(y.dtype, np.unsignedinteger): 174 | raise ValueError("y must be signed") 175 | 176 | if not thres_abs: 177 | thres = thres * (np.max(y) - np.min(y)) + np.min(y) 178 | 179 | min_dist = int(min_dist) 180 | 181 | # compute first order difference 182 | dy = np.diff(y) 183 | 184 | # propagate left and right values successively to fill all plateau pixels (0-value) 185 | zeros, = np.where(dy == 0) 186 | 187 | # check if the signal is totally flat 188 | if len(zeros) == len(y) - 1: 189 | return np.array([]) 190 | 191 | if len(zeros): 192 | # compute first order difference of zero indexes 193 | zeros_diff = np.diff(zeros) 194 | # check when zeros are not chained together 195 | zeros_diff_not_one, = np.add(np.where(zeros_diff != 1), 1) 196 | # make an array of the chained zero indexes 197 | zero_plateaus = np.split(zeros, zeros_diff_not_one) 198 | 199 | # fix if leftmost value in dy is zero 200 | if zero_plateaus[0][0] == 0: 201 | dy[zero_plateaus[0]] = dy[zero_plateaus[0][-1] + 1] 202 | zero_plateaus.pop(0) 203 | 204 | # fix if rightmost value of dy is zero 205 | if len(zero_plateaus) and zero_plateaus[-1][-1] == len(dy) - 1: 206 | dy[zero_plateaus[-1]] = dy[zero_plateaus[-1][0] - 1] 207 | zero_plateaus.pop(-1) 208 | 209 | # for each chain of zero indexes 210 | for plateau in zero_plateaus: 211 | median = np.median(plateau) 212 | # set leftmost values to leftmost non zero values 213 | dy[plateau[plateau < median]] = dy[plateau[0] - 1] 214 | # set rightmost and middle values to rightmost non zero values 215 | dy[plateau[plateau >= median]] = dy[plateau[-1] + 1] 216 | 217 | # find the peaks by using the first order difference 218 | peaks = np.where( 219 | (np.hstack([dy, 0.0]) < 0.0) 220 | & (np.hstack([0.0, dy]) > 0.0) 221 | & (np.greater(y, thres)) 222 | )[0] 223 | 224 | # handle multiple peaks, respecting the minimum distance 225 | if peaks.size > 1 and min_dist > 1: 226 | highest = peaks[np.argsort(y[peaks])][::-1] 227 | rem = np.ones(y.size, dtype=bool) 228 | rem[peaks] = False 229 | 230 | for peak in highest: 231 | if not rem[peak]: 232 | sl = slice(max(0, peak - min_dist), peak + min_dist + 1) 233 | rem[sl] = True 234 | rem[peak] = False 235 | 236 | peaks = np.arange(y.size)[~rem] 237 | 238 | return peaks 239 | 240 | 241 | def readcal(width): 242 | #read in the calibration points 243 | #compute second or third order polynimial, and generate wavelength array! 244 | #Les Wright 28 Sept 2022 245 | errors = 0 246 | message = 0 #variable to store returned message data 247 | try: 248 | print("Loading calibration data...") 249 | file = open('caldata.txt', 'r') 250 | except: 251 | errors = 1 252 | 253 | try: 254 | #read both the pixel numbers and wavelengths into two arrays. 255 | lines = file.readlines() 256 | line0 = lines[0].strip() #strip newline 257 | pixels = line0.split(',') #split on , 258 | pixels = [int(i) for i in pixels] #convert list of strings to ints 259 | line1 = lines[1].strip() 260 | wavelengths = line1.split(',') 261 | wavelengths = [float(i) for i in wavelengths]#convert list of strings to floats 262 | except: 263 | errors = 1 264 | 265 | try: 266 | if (len(pixels) != len(wavelengths)): 267 | #The Calibration points are of unequal length! 268 | errors = 1 269 | if (len(pixels) < 3): 270 | #The Cal data contains less than 3 pixels! 271 | errors = 1 272 | if (len(wavelengths) < 3): 273 | #The Cal data contains less than 3 wavelengths! 274 | errors = 1 275 | except: 276 | errors = 1 277 | 278 | if errors == 1: 279 | print("Loading of Calibration data failed (missing caldata.txt or corrupted data!") 280 | print("Loading placeholder data...") 281 | print("You MUST perform a Calibration to use this software!\n\n") 282 | pixels = [0,400,800] 283 | wavelengths = [380,560,750] 284 | 285 | 286 | #create an array for the data... 287 | wavelengthData = [] 288 | 289 | if (len(pixels) == 3): 290 | print("Calculating second order polynomial...") 291 | coefficients = np.poly1d(np.polyfit(pixels, wavelengths, 2)) 292 | print(coefficients) 293 | C1 = coefficients[2] 294 | C2 = coefficients[1] 295 | C3 = coefficients[0] 296 | print("Generating Wavelength Data!\n\n") 297 | for pixel in range(width): 298 | wavelength=((C1*pixel**2)+(C2*pixel)+C3) 299 | wavelength = round(wavelength,6) #because seriously! 300 | wavelengthData.append(wavelength) 301 | print("Done! Note that calibration with only 3 wavelengths will not be accurate!") 302 | if errors == 1: 303 | message = 0 #return message zero(errors) 304 | else: 305 | message = 1 #return message only 3 wavelength cal secodn order poly (Inaccurate) 306 | 307 | if (len(pixels) > 3): 308 | print("Calculating third order polynomial...") 309 | coefficients = np.poly1d(np.polyfit(pixels, wavelengths, 3)) 310 | print(coefficients) 311 | #note this pulls out extremely precise numbers. 312 | #this causes slight differences in vals then when we compute manual, but hey ho, more precision 313 | #that said, we waste that precision later, but tbh, we wouldn't get that kind of precision in 314 | #the real world anyway! 1/10 of a nm is more than adequate! 315 | C1 = coefficients[3] 316 | C2 = coefficients[2] 317 | C3 = coefficients[1] 318 | C4 = coefficients[0] 319 | ''' 320 | print(C1) 321 | print(C2) 322 | print(C3) 323 | print(C4) 324 | ''' 325 | print("Generating Wavelength Data!\n\n") 326 | for pixel in range(width): 327 | wavelength=((C1*pixel**3)+(C2*pixel**2)+(C3*pixel)+C4) 328 | wavelength = round(wavelength,6) 329 | wavelengthData.append(wavelength) 330 | 331 | #final job, we need to compare all the recorded wavelenths with predicted wavelengths 332 | #and note the deviation! 333 | #do something if it is too big! 334 | predicted = [] 335 | #iterate over the original pixelnumber array and predict results 336 | for i in pixels: 337 | px = i 338 | y=((C1*px**3)+(C2*px**2)+(C3*px)+C4) 339 | predicted.append(y) 340 | 341 | #calculate 2 squared of the result 342 | #if this is close to 1 we are all good! 343 | corr_matrix = np.corrcoef(wavelengths, predicted) 344 | corr = corr_matrix[0,1] 345 | R_sq = corr**2 346 | 347 | print("R-Squared="+str(R_sq)) 348 | 349 | message = 2 #Multiwavelength cal, 3rd order poly 350 | 351 | 352 | if message == 0: 353 | calmsg1 = "UNCALIBRATED!" 354 | calmsg2 = "Defaults loaded" 355 | calmsg3 = "Perform Calibration!" 356 | if message == 1: 357 | calmsg1 = "Calibrated!!" 358 | calmsg2 = "Using 3 cal points" 359 | calmsg3 = "2nd Order Polyfit" 360 | if message == 2: 361 | calmsg1 = "Calibrated!!!" 362 | calmsg2 = "Using > 3 cal points" 363 | calmsg3 = "3rd Order Polyfit" 364 | 365 | returndata = [] 366 | returndata.append(wavelengthData) 367 | returndata.append(calmsg1) 368 | returndata.append(calmsg2) 369 | returndata.append(calmsg3) 370 | return returndata 371 | 372 | 373 | def writecal(clickArray): 374 | calcomplete = False 375 | pxdata = [] 376 | wldata = [] 377 | print("Enter known wavelengths for observed pixels!") 378 | for i in clickArray: 379 | pixel = i[0] 380 | wavelength = input("Enter wavelength for: "+str(pixel)+"px:") 381 | pxdata.append(pixel) 382 | wldata.append(wavelength) 383 | #This try except serves two purposes 384 | #first I want to write data to the caldata.txt file without quotes 385 | #second it validates the data in as far as no strings were entered 386 | try: 387 | wldata = [float(x) for x in wldata] 388 | except: 389 | print("Only ints or decimals are allowed!") 390 | print("Calibration aborted!") 391 | 392 | pxdata = ','.join(map(str, pxdata)) #convert array to string 393 | wldata = ','.join(map(str, wldata)) #convert array to string 394 | f = open('caldata.txt','w') 395 | f.write(pxdata+'\r\n') 396 | f.write(wldata+'\r\n') 397 | print("Calibration Data Written!") 398 | calcomplete = True 399 | return calcomplete 400 | 401 | def generateGraticule(wavelengthData): 402 | low = wavelengthData[0] #get lowet number in list 403 | high = wavelengthData[len(wavelengthData)-1] #get highest number 404 | #round and int these numbers so we have our range of numbers to look at 405 | #give a margin of 10 at each end for good measure 406 | low = int(round(low))-10 407 | high = int(round(high))+10 408 | #print('...') 409 | #print(low) 410 | #print(high) 411 | #print('...') 412 | returndata = [] 413 | #find positions of every whole 10nm 414 | tens = [] 415 | for i in range(low,high): 416 | if (i%10==0): 417 | #position contains pixelnumber and wavelength 418 | position = min(enumerate(wavelengthData), key=lambda x: abs(i - x[1])) 419 | #If the difference between the target and result is <9 show the line 420 | #(otherwise depending on the scale we get dozens of number either end that are close to the target) 421 | if abs(i-position[1]) <1: 422 | #print(i) 423 | #print(position) 424 | tens.append(position[0]) 425 | returndata.append(tens) 426 | fifties = [] 427 | for i in range(low,high): 428 | if (i%50==0): 429 | #position contains pixelnumber and wavelength 430 | position = min(enumerate(wavelengthData), key=lambda x: abs(i - x[1])) 431 | #If the difference between the target and result is <1 show the line 432 | #(otherwise depending on the scale we get dozens of number either end that are close to the target) 433 | if abs(i-position[1]) <1: 434 | labelpos = position[0] 435 | labeltxt = int(round(position[1])) 436 | labeldata = [labelpos,labeltxt] 437 | fifties.append(labeldata) 438 | returndata.append(fifties) 439 | return returndata 440 | 441 | 442 | background = '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' 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 | 463 | 464 | --------------------------------------------------------------------------------