├── spectrum_sample.png ├── Dockerfile ├── LICENSE ├── README.md ├── .gitignore └── spectrometer.py /spectrum_sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonbut/RPiSpectrometer/HEAD/spectrum_sample.png -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM resin/rpi-raspbian 2 | 3 | # Install dependencies 4 | RUN apt-get update && apt-get install -y \ 5 | vim \ 6 | python3 \ 7 | python3-pip \ 8 | python3-pil \ 9 | libjpeg8 \ 10 | libjpeg8-dev \ 11 | libfreetype6 \ 12 | libfreetype6-dev \ 13 | zlib1g \ 14 | python3-picamera \ 15 | fonts-lato 16 | 17 | # Add local volume for code 18 | ADD . /src -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Tony Butterfield 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 | # RPi Spectrometer 2 | Spectrometer for Raspberry Pi using a Pi Camera 3 | 4 | ![Example Spectrograph](spectrum_sample.png) 5 | 6 | This is a fully functioning spectrometer. It captures an image from a Pi camera with diffraction grating in front and will analyse spectrum and generate a nice tidy spectrograph chart as a PNG. 7 | 8 | For more details please see blog post at [Durable Scope](http://blog.durablescope.com/post/BuiltASpectrometer/) 9 | 10 | ## Getting Started 11 | Run ```docker build -t [your_desired_image_name] .``` 12 | 13 | Then ```docker run --device=/dev/vchiq -i -t [your_image_name_from_above] /bin/bash``` 14 | 15 | All of the files located in this directory will be mounted at ```/src```, run 16 | ```cd src``` to navigate there. 17 | 18 | From here, all you have to do is run the capture command ```python3 spectrometer.py [file_name_for_images] [shutter_time_microseconds]``` eg. ```python3 spectrometer.py my_first_spectometer 100000``` 19 | 20 | ## Deploying to a Pi Device 21 | Take a micro-SD card and drop the source code from this repository onto a desired location. 22 | 23 | Take SD-card and insert it into the Raspberry Pi device, connect via ssh or attached keyboard and monitor. 24 | 25 | Follow the Dockerfile steps manually to install required dependencies and operate on the actual SD card OS. 26 | -------------------------------------------------------------------------------- /.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 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # PyCharm settings 86 | .idea 87 | 88 | # Spyder project settings 89 | .spyderproject 90 | 91 | # Rope project settings 92 | .ropeproject 93 | -------------------------------------------------------------------------------- /spectrometer.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import math 3 | import time 4 | import picamera 5 | from fractions import Fraction 6 | from collections import OrderedDict 7 | from PIL import Image, ImageDraw, ImageFile, ImageFont 8 | 9 | 10 | # scan a column to determine top and bottom of area of lightness 11 | def get_spectrum_y_bound(pix, x, middle_y, spectrum_threshold, spectrum_threshold_duration): 12 | c = 0 13 | spectrum_top = middle_y 14 | for y in range(middle_y, 0, -1): 15 | r, g, b = pix[x, y] 16 | brightness = r + g + b 17 | if brightness < spectrum_threshold: 18 | c = c + 1 19 | if c > spectrum_threshold_duration: 20 | break 21 | else: 22 | spectrum_top = y 23 | c = 0 24 | 25 | c = 0 26 | spectrum_bottom = middle_y 27 | for y in range(middle_y, middle_y * 2, 1): 28 | r, g, b = pix[x, y] 29 | brightness = r + g + b 30 | if brightness < spectrum_threshold: 31 | c = c + 1 32 | if c > spectrum_threshold_duration: 33 | break 34 | else: 35 | spectrum_bottom = y 36 | c = 0 37 | 38 | return spectrum_top, spectrum_bottom 39 | 40 | 41 | # find aperture on right hand side of image along middle line 42 | def find_aperture(pic_pixels, pic_width: int, pic_height: int) -> object: 43 | middle_x = int(pic_width / 2) 44 | middle_y = int(pic_height / 2) 45 | aperture_brightest = 0 46 | aperture_x = 0 47 | for x in range(middle_x, pic_width, 1): 48 | r, g, b = pic_pixels[x, middle_y] 49 | brightness = r + g + b 50 | if brightness > aperture_brightest: 51 | aperture_brightest = brightness 52 | aperture_x = x 53 | 54 | aperture_threshold = aperture_brightest * 0.9 55 | aperture_x1 = aperture_x 56 | for x in range(aperture_x, middle_x, -1): 57 | r, g, b = pic_pixels[x, middle_y] 58 | brightness = r + g + b 59 | if brightness < aperture_threshold: 60 | aperture_x1 = x 61 | break 62 | 63 | aperture_x2 = aperture_x 64 | for x in range(aperture_x, pic_width, 1): 65 | r, g, b = pic_pixels[x, middle_y] 66 | brightness = r + g + b 67 | if brightness < aperture_threshold: 68 | aperture_x2 = x 69 | break 70 | 71 | aperture_x = (aperture_x1 + aperture_x2) / 2 72 | 73 | spectrum_threshold_duration = 64 74 | aperture_y_bounds = get_spectrum_y_bound(pic_pixels, aperture_x, middle_y, aperture_threshold, spectrum_threshold_duration) 75 | aperture_y = (aperture_y_bounds[0] + aperture_y_bounds[1]) / 2 76 | aperture_height = (aperture_y_bounds[1] - aperture_y_bounds[0]) * 1.0 77 | 78 | return {'x': aperture_x, 'y': aperture_y, 'h': aperture_height, 'b': aperture_brightest} 79 | 80 | 81 | # draw aperture onto image 82 | def draw_aperture(aperture, draw): 83 | fill_color = "#000" 84 | draw.line((aperture['x'], aperture['y'] - aperture['h'] / 2, aperture['x'], aperture['y'] + aperture['h'] / 2), 85 | fill=fill_color) 86 | 87 | 88 | # draw scan line 89 | def draw_scan_line(aperture, draw, spectrum_angle): 90 | fill_color = "#888" 91 | xd = aperture['x'] 92 | h = aperture['h'] / 2 93 | y0 = math.tan(spectrum_angle) * xd + aperture['y'] 94 | draw.line((0, y0 - h, aperture['x'], aperture['y'] - h), fill=fill_color) 95 | draw.line((0, y0 + h, aperture['x'], aperture['y'] + h), fill=fill_color) 96 | 97 | 98 | # return an RGB visual representation of wavelength for chart 99 | # Based on: http://www.efg2.com/Lab/ScienceAndEngineering/Spectra.htm 100 | # The foregoing is based on: http://www.midnightkite.com/color.html 101 | # thresholds = [ 380, 440, 490, 510, 580, 645, 780 ] 102 | # vio blu cyn gre yel org red 103 | def wavelength_to_color(lambda2): 104 | factor = 0.0 105 | color = [0, 0, 0] 106 | thresholds = [380, 400, 450, 465, 520, 565, 780] 107 | for i in range(0, len(thresholds) - 1, 1): 108 | t1 = thresholds[i] 109 | t2 = thresholds[i + 1] 110 | if lambda2 < t1 or lambda2 >= t2: 111 | continue 112 | if i % 2 != 0: 113 | tmp = t1 114 | t1 = t2 115 | t2 = tmp 116 | if i < 5: 117 | color[i % 3] = (lambda2 - t2) / (t1 - t2) 118 | color[2 - int(i / 2)] = 1.0 119 | factor = 1.0 120 | break 121 | 122 | # Let the intensity fall off near the vision limits 123 | if 380 <= lambda2 < 420: 124 | factor = 0.2 + 0.8 * (lambda2 - 380) / (420 - 380) 125 | elif 600 <= lambda2 < 780: 126 | factor = 0.2 + 0.8 * (780 - lambda2) / (780 - 600) 127 | return int(255 * color[0] * factor), int(255 * color[1] * factor), int(255 * color[2] * factor) 128 | 129 | 130 | def take_picture(name, shutter): 131 | print("initialising camera") 132 | camera = picamera.PiCamera() 133 | try: 134 | print("allowing camera to warmup") 135 | camera.vflip = True 136 | camera.framerate = Fraction(1, 2) 137 | camera.shutter_speed = shutter 138 | camera.iso = 100 139 | camera.exposure_mode = 'off' 140 | camera.awb_mode = 'off' 141 | camera.awb_gains = (1, 1) 142 | time.sleep(3) 143 | print("capturing image") 144 | camera.capture(name, resize=(1296, 972)) 145 | finally: 146 | camera.close() 147 | return name 148 | 149 | 150 | def draw_graph(draw, pic_pixels, aperture: object, spectrum_angle, wavelength_factor): 151 | aperture_height = aperture['h'] / 2 152 | step = 1 153 | last_graph_y = 0 154 | max_result = 0 155 | results = OrderedDict() 156 | for x in range(0, int(aperture['x'] * 7 / 8), step): 157 | wavelength = (aperture['x'] - x) * wavelength_factor 158 | if 1000 < wavelength or wavelength < 380: 159 | continue 160 | 161 | # general efficiency curve of 1000/mm grating 162 | eff = (800 - (wavelength - 250)) / 800 163 | if eff < 0.3: 164 | eff = 0.3 165 | 166 | # notch near yellow maybe caused by camera sensitivity 167 | mid = 571 168 | width = 14 169 | if (mid - width) < wavelength < (mid + width): 170 | d = (width - abs(wavelength - mid)) / width 171 | eff = eff * (1 - d * 0.12) 172 | 173 | # up notch near 590 174 | #mid = 588 175 | #width = 10 176 | #if (mid - width) < wavelength < (mid + width): 177 | # d = (width - abs(wavelength - mid)) / width 178 | # eff = eff * (1 + d * 0.1) 179 | 180 | y0 = math.tan(spectrum_angle) * (aperture['x'] - x) + aperture['y'] 181 | amplitude = 0 182 | ac = 0.0 183 | for y in range(int(y0 - aperture_height), int(y0 + aperture_height), 1): 184 | r, g, b = pic_pixels[x, y] 185 | q = r + b + g * 2 186 | if y < (y0 - aperture_height + 2) or y > (y0 + aperture_height - 3): 187 | q = q * 0.5 188 | amplitude = amplitude + q 189 | ac = ac + 1.0 190 | amplitude = amplitude / ac / eff 191 | # amplitude=1/eff 192 | results[str(wavelength)] = amplitude 193 | if amplitude > max_result: 194 | max_result = amplitude 195 | graph_y = amplitude / 50 * aperture_height 196 | draw.line((x - step, y0 + aperture_height - last_graph_y, x, y0 + aperture_height - graph_y), fill="#fff") 197 | last_graph_y = graph_y 198 | draw_ticks_and_frequencies(draw, aperture, spectrum_angle, wavelength_factor) 199 | return results, max_result 200 | 201 | 202 | def draw_ticks_and_frequencies(draw, aperture, spectrum_angle, wavelength_factor): 203 | aperture_height = aperture['h'] / 2 204 | for wl in range(400, 1001, 50): 205 | x = aperture['x'] - (wl / wavelength_factor) 206 | y0 = math.tan(spectrum_angle) * (aperture['x'] - x) + aperture['y'] 207 | draw.line((x, y0 + aperture_height + 5, x, y0 + aperture_height - 5), fill="#fff") 208 | font = ImageFont.truetype('/usr/share/fonts/truetype/lato/Lato-Regular.ttf', 12) 209 | draw.text((x, y0 + aperture_height + 15), str(wl), font=font, fill="#fff") 210 | 211 | 212 | def inform_user_of_exposure(max_result): 213 | exposure = max_result / (255 + 255 + 255) 214 | print("ideal exposure between 0.15 and 0.30") 215 | print("exposure=", exposure) 216 | if exposure < 0.15: 217 | print("consider increasing shutter time") 218 | elif exposure > 0.3: 219 | print("consider reducing shutter time") 220 | 221 | 222 | def save_image_with_overlay(im, name): 223 | output_filename = name + "_out.jpg" 224 | ImageFile.MAXBLOCK = 2 ** 20 225 | im.save(output_filename, "JPEG", quality=80, optimize=True, progressive=True) 226 | 227 | 228 | def normalize_results(results, max_result): 229 | for wavelength in results: 230 | results[wavelength] = results[wavelength] / max_result 231 | return results 232 | 233 | 234 | def export_csv(name, normalized_results): 235 | csv_filename = name + ".csv" 236 | csv = open(csv_filename, 'w') 237 | csv.write("wavelength,amplitude\n") 238 | for wavelength in normalized_results: 239 | csv.write(wavelength) 240 | csv.write(",") 241 | csv.write("{:0.3f}".format(normalized_results[wavelength])) 242 | csv.write("\n") 243 | csv.close() 244 | 245 | 246 | def export_diagram(name, normalized_results): 247 | antialias = 4 248 | w = 600 * antialias 249 | h2 = 300 * antialias 250 | 251 | h = h2 - 20 * antialias 252 | sd = Image.new('RGB', (w, h2), (255, 255, 255)) 253 | draw = ImageDraw.Draw(sd) 254 | 255 | w1 = 380.0 256 | w2 = 780.0 257 | f1 = 1.0 / w1 258 | f2 = 1.0 / w2 259 | for x in range(0, w, 1): 260 | # Iterate across frequencies, not wavelengths 261 | lambda2 = 1.0 / (f1 - (float(x) / float(w) * (f1 - f2))) 262 | c = wavelength_to_color(lambda2) 263 | draw.line((x, 0, x, h), fill=c) 264 | 265 | pl = [(w, 0), (w, h)] 266 | for wavelength in normalized_results: 267 | wl = float(wavelength) 268 | x = int((wl - w1) / (w2 - w1) * w) 269 | # print wavelength,x 270 | pl.append((int(x), int((1 - normalized_results[wavelength]) * h))) 271 | pl.append((0, h)) 272 | pl.append((0, 0)) 273 | draw.polygon(pl, fill="#FFF") 274 | draw.polygon(pl) 275 | 276 | font = ImageFont.truetype('/usr/share/fonts/truetype/lato/Lato-Regular.ttf', 12 * antialias) 277 | draw.line((0, h, w, h), fill="#000", width=antialias) 278 | 279 | for wl in range(400, 1001, 10): 280 | x = int((float(wl) - w1) / (w2 - w1) * w) 281 | draw.line((x, h, x, h + 3 * antialias), fill="#000", width=antialias) 282 | 283 | for wl in range(400, 1001, 50): 284 | x = int((float(wl) - w1) / (w2 - w1) * w) 285 | draw.line((x, h, x, h + 5 * antialias), fill="#000", width=antialias) 286 | wls = str(wl) 287 | tx = draw.textsize(wls, font=font) 288 | draw.text((x - tx[0] / 2, h + 5 * antialias), wls, font=font, fill="#000") 289 | 290 | # save chart 291 | sd = sd.resize((int(w / antialias), int(h / antialias)), Image.ANTIALIAS) 292 | output_filename = name + "_chart.png" 293 | sd.save(output_filename, "PNG", quality=95, optimize=True, progressive=True) 294 | 295 | 296 | def main(): 297 | # 1. Take picture 298 | name = sys.argv[1] 299 | shutter = int(sys.argv[2]) 300 | raw_filename = name + "_raw.jpg" 301 | take_picture(raw_filename,shutter) 302 | 303 | # 2. Get picture's aperture 304 | im = Image.open(raw_filename) 305 | print("locating aperture") 306 | pic_pixels = im.load() 307 | aperture = find_aperture(pic_pixels, im.size[0], im.size[1]) 308 | 309 | # 3. Draw aperture and scan line 310 | spectrum_angle = -0.01 311 | draw = ImageDraw.Draw(im) 312 | draw_aperture(aperture, draw) 313 | draw_scan_line(aperture, draw, spectrum_angle) 314 | 315 | # 4. Draw graph on picture 316 | print("analysing image") 317 | wavelength_factor = 0.95 318 | #wavelength_factor = 0.892 # 1000/mm 319 | #wavelength_factor=0.892*2.0*600/650 # 500/mm 320 | results, max_result = draw_graph(draw, pic_pixels, aperture, spectrum_angle, wavelength_factor) 321 | 322 | # 5. Inform user of issues with exposure 323 | inform_user_of_exposure(max_result) 324 | 325 | # 6. Save picture with overlay 326 | save_image_with_overlay(im, name) 327 | 328 | # 7. Normalize results for export 329 | print("exporting CSV") 330 | normalized_results = normalize_results(results, max_result) 331 | 332 | # 8. Save csv of results 333 | export_csv(name, normalized_results) 334 | 335 | # 9. Generate spectrum diagram 336 | print("generating chart") 337 | export_diagram(name, normalized_results) 338 | 339 | 340 | main() 341 | --------------------------------------------------------------------------------