├── .gitignore ├── LICENSE ├── README.md ├── camera.py ├── config.py ├── pathMaker.py ├── run_tests.sh ├── take.py ├── test_camera.py ├── test_exposurecalc.py └── test_pathMaker.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Alex Ellis 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 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | phototimer - create timelapses with your Raspberry Pi 2 | ========== 3 | 4 | phototimer gives you a smart way to capture photos for your timelapses. It is smart because it only takes pictures between the hours you specify and creates a useful folder structure. It is simple because it only depends on Python and `raspistill` both of which are normally already available. 5 | 6 | ### Blog posts on phototimer: 7 | 8 | * [Build a Timelapse Rig with your Raspberry Pi](http://blog.alexellis.io/raspberry-pi-timelapse/) 9 | 10 | * [Portable wildlife timelapse](http://blog.alexellis.io/centreparcs-timelapse/) 11 | 12 | Example videos: 13 | 14 | * [YouTube playlist created with PhotoTimer](https://www.youtube.com/playlist?list=PLlIapFDp305Am5KuvdUInmAEjKXLBYKW2) 15 | 16 | How does it work? 17 | ------------------ 18 | 19 | Start phototimer through a terminal, `ssh` connection or `@reboot crontab` specifying the amount of seconds between photos after that. By default photos are stored in `/mnt/usbflash`, but this is configurable along with daylight hours and the quality level of the photos. 20 | 21 | This is an example `config.py` file which should create files that are about 2MB in size: 22 | 23 | ```python 24 | config = {} 25 | config["am"] = 400 26 | config["pm"] = 2000 27 | 28 | config["flip_horizontal"] = True 29 | config["flip_vertical"] = False 30 | config["metering_mode"] = "matrix" 31 | 32 | config["base_path"] = "/var/image" 33 | config["height"] = 1536 34 | config["width"] = 2048 35 | config["quality"] = 35 36 | ``` 37 | 38 | 39 | Usage 40 | ----- 41 | ``` 42 | $ python take.py 60 & 43 | ``` 44 | 45 | This will takes a photo every 60 seconds. The default base folder is /mnt/usbflash, photos are then put in a folder such as: 46 | Output file format 47 | ----------------- 48 | /2014/11/20/762132131.jpg 49 | /yyyy/mm/hh/milliseconds 50 | 51 | * Default hours to take images is between 7am and 5pm + 1 hour either side 52 | * Designed to be run constantly - with the quality settings this equates to about 1gb of JPG images per day 53 | 54 | 55 | Troubleshooting 56 | --------------- 57 | If you find that phototimer is automatically exiting then you may want to use a tool like `screen` to make sure you can keep an eye on the process. 58 | 59 | ``` 60 | $ screen 61 | $ cd phototimer 62 | $ python2 take.py 60 63 | [Control A + D] 64 | ``` 65 | 66 | To reconnect later type in `screen -r`. 67 | 68 | Unit testing 69 | ------------ 70 | 71 | The exposure calculations and some other functions have been unit tested, if you change the code or want to extend it please look at these before contributing. 72 | 73 | Here's how you run them: 74 | 75 | ``` 76 | chmod 700 ./run_tests.sh 77 | ./run_tests.sh 78 | ``` 79 | 80 | Feedback? 81 | --------- 82 | 83 | Please get in touch with me on Twitter [@alexellisuk](https://twitter.com/alexellisuk) if you have any requests, comments or suggestions. If you run into problems then you could also raise a Github issue. 84 | -------------------------------------------------------------------------------- /camera.py: -------------------------------------------------------------------------------- 1 | class exposureCalc: 2 | def __init__(self, sunrise, sunset): 3 | self.sunrise=sunrise 4 | self.sunset=sunset 5 | def get_exposure(self, time): 6 | if(time >=self.sunrise and time <=self.sunset): 7 | return 'auto' 8 | return 'night' 9 | 10 | #One hour either side of sunrise/set 11 | def take_shot(self, time): 12 | if(time >=self.sunrise and time <=self.sunset): 13 | return True 14 | return False 15 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | config = {} 2 | config["am"] = 400 3 | config["pm"] = 2000 4 | 5 | config["flip_horizontal"] = False 6 | config["flip_vertical"] = False 7 | config["metering_mode"] = "matrix" 8 | 9 | config["base_path"] = "/var/image" 10 | config["height"] = 1536 11 | config["width"] = 2048 12 | config["quality"] = 35 13 | -------------------------------------------------------------------------------- /pathMaker.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | class pathMaker: 4 | def __init__(self): 5 | pass 6 | def get_parts(self, totalPath): 7 | if(totalPath==None): 8 | return [] 9 | return totalPath.strip("/").split("/") 10 | 11 | def get_paths(self, totalPath): 12 | parts = self.get_parts(totalPath) 13 | paths =[] 14 | path = "/" 15 | for p in parts: 16 | path = os.path.join(path,p) 17 | paths.append(path) 18 | return paths 19 | 20 | def make_path(self, totalPath): 21 | pass 22 | 23 | -------------------------------------------------------------------------------- /run_tests.sh: -------------------------------------------------------------------------------- 1 | python -m unittest test_pathMaker test_camera 2 | -------------------------------------------------------------------------------- /take.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import time 4 | from datetime import datetime 5 | from camera import exposureCalc 6 | from config import config 7 | 8 | def try_to_mkdir(path): 9 | if os.path.exists(path) == False: 10 | os.makedirs(path) 11 | 12 | def prepare_dir(base, now): 13 | path = str(now.year) 14 | try_to_mkdir(base + "/" +path) 15 | 16 | path = str(now.year) + "/" + str(now.month) 17 | try_to_mkdir(base + "/" +path) 18 | 19 | path = str( datetime.now().year) + "/" + str( datetime.now().month)+"/"+ str( datetime.now().day) 20 | try_to_mkdir(base + "/" +path) 21 | 22 | path = str( datetime.now().year) + "/" + str( datetime.now().month)+"/"+ str( datetime.now().day)+"/"+ str( datetime.now().hour) 23 | try_to_mkdir(base + "/" +path) 24 | return path 25 | 26 | def make_os_command(config, exposureMode , file_name): 27 | height = config["height"] 28 | width = config["width"] 29 | 30 | os_command = "/opt/vc/bin/raspistill -q "+str(config["quality"])+" " 31 | if(config["flip_horizontal"]): 32 | os_command = os_command + "-hf " 33 | if(config["flip_vertical"]): 34 | os_command = os_command + "-vf " 35 | 36 | os_command = os_command + "-h "+str(height)+\ 37 | " -w "+str(width)+\ 38 | " --exposure " +exposureMode +\ 39 | " --metering " + config["metering_mode"] +\ 40 | " -o "+file_name 41 | return os_command 42 | 43 | def run_loop(base, pause, config): 44 | am = config["am"] 45 | pm = config["pm"] 46 | exposureCalc1= exposureCalc(am, pm) 47 | 48 | current_milli_time = lambda: int(round(time.time() * 1000)) 49 | 50 | print("Pause : " + str(pause)) 51 | 52 | while True: 53 | hoursMinutes = int(time.strftime("%H%M")) 54 | exposureMode = exposureCalc1.get_exposure(hoursMinutes) 55 | take_shot = exposureCalc1.take_shot(hoursMinutes) 56 | 57 | if (take_shot == True): 58 | now = datetime.now() 59 | path = prepare_dir(base, now) 60 | 61 | mili = str(current_milli_time()) 62 | name=path.replace("/", "_") + "_" + mili + ".jpg" 63 | print("Capturing " + name + " in " + exposureMode + " mode") 64 | file_name = base + "/" + path + "/" + name 65 | 66 | os_command = make_os_command(config, exposureMode, file_name) 67 | os.system(os_command) 68 | print("Written: " + file_name) 69 | else: 70 | print("Shot cancelled during hours of darkness") 71 | 72 | time.sleep(pause) 73 | 74 | if(__name__ == '__main__'): 75 | if len(sys.argv) < 1: 76 | exit() 77 | else: 78 | try: 79 | pauseInterval = int(sys.argv[1]) 80 | basePath=config["base_path"] 81 | run_loop(basePath,pauseInterval, config) 82 | except KeyboardInterrupt: 83 | print ("Cancelling take.py") 84 | -------------------------------------------------------------------------------- /test_camera.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from camera import exposureCalc 3 | 4 | class test_camera(unittest.TestCase): 5 | def test_exposureCalc_lowerBounds(self): 6 | exposureCalc1=exposureCalc(700,1700) 7 | self.assertEqual(exposureCalc1.get_exposure(700), "auto") 8 | def test_exposureCalc_upperBounds(self): 9 | exposureCalc1=exposureCalc(700,1700) 10 | self.assertEqual(exposureCalc1.get_exposure(1700), "auto") 11 | def test_exposureCalc_middleBounds(self): 12 | exposureCalc1=exposureCalc(700,1700) 13 | self.assertEqual(exposureCalc1.get_exposure(1245), "auto") 14 | 15 | def test_exposureCalc_outsideUpperBounds(self): 16 | exposureCalc1=exposureCalc(700,1700) 17 | self.assertEqual(exposureCalc1.get_exposure(2130), "night") 18 | 19 | def test_exposureCalc_outsideLowerBounds(self): 20 | exposureCalc1=exposureCalc(700,1700) 21 | self.assertEqual(exposureCalc1.get_exposure(600), "night") 22 | 23 | def test_take_shot(self): 24 | exp=exposureCalc(700,1700) 25 | self.assertEqual(exp.take_shot(1700), True) 26 | self.assertEqual(exp.take_shot(1245), True) 27 | self.assertEqual(exp.take_shot(700), True) 28 | self.assertEqual(exp.take_shot(699), False) 29 | 30 | self.assertEqual(exp.take_shot(0), False) 31 | self.assertEqual(exp.take_shot(500), False) 32 | self.assertEqual(exp.take_shot(2100), False) 33 | 34 | def test_take_shot_rangetest(self): 35 | exp=exposureCalc(700, 1700) 36 | for x in range(0,2300): 37 | val = exp.take_shot(x) 38 | # print(str(x) + " = (x) = " + str(val)) 39 | if(x >= 700 and x <= 1700): 40 | self.assertTrue(val) 41 | else: 42 | self.assertFalse(val) 43 | 44 | def test_exposureCalc_rangetest(self): 45 | exp=exposureCalc(700, 1700) 46 | for x in range(0,2300): 47 | value = exp.get_exposure(x) 48 | self.assertTrue(value in ('auto','night'), 'value: ' + str(value) + ' not in collection') 49 | 50 | if __name__ == '__main__': 51 | unittest.main() 52 | -------------------------------------------------------------------------------- /test_exposurecalc.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from camera import exposureCalc 3 | 4 | class test_exposureCalc(unittest.TestCase): 5 | def test_exposureCalc_lowerBounds_noshot(self): 6 | am = 500 7 | pm = 2000 8 | exposureCalc1= exposureCalc(am, pm) 9 | 10 | hoursMinutes = 400 11 | take_shot = exposureCalc1.take_shot(hoursMinutes) 12 | self.assertEquals(False, take_shot) 13 | 14 | def test_exposureCalc_upperBounds_noshot(self): 15 | am = 500 16 | pm = 2000 17 | exposureCalc1= exposureCalc(am, pm) 18 | 19 | hoursMinutes = 2001 20 | take_shot = exposureCalc1.take_shot(hoursMinutes) 21 | self.assertEquals(False, take_shot) 22 | 23 | if __name__ == '__main__': 24 | unittest.main() 25 | -------------------------------------------------------------------------------- /test_pathMaker.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from pathMaker import pathMaker 3 | 4 | class test_pathMaker(unittest.TestCase): 5 | 6 | def test_pathMaker_get_parts_empty(self): 7 | empty = '' 8 | p=pathMaker() 9 | pts = p.get_parts(empty) 10 | self.assertEquals(len(pts), 1, 'Value = ') 11 | 12 | def test_pathMaker_get_parts(self): 13 | withYear = '/tmp/flash/2014' 14 | p=pathMaker() 15 | pts = p.get_parts(withYear) 16 | self.assertEquals('tmp', pts[0]) 17 | self.assertEquals('flash', pts[1]) 18 | self.assertEquals('2014', pts[2]) 19 | self.assertEquals(len(pts), 3) 20 | 21 | def test_get_paths(self): 22 | withYear = '/tmp/flash/2014/3/11' 23 | p=pathMaker() 24 | paths = p.get_paths(withYear) 25 | self.assertEquals( len(paths), 5 ) 26 | self.assertEquals("/tmp", paths[0]) 27 | self.assertEquals("/tmp/flash", paths[1]) 28 | self.assertEquals("/tmp/flash/2014", paths[2]) 29 | self.assertEquals("/tmp/flash/2014/3", paths[3]) 30 | self.assertEquals("/tmp/flash/2014/3/11", paths[4]) 31 | 32 | 33 | 34 | if __name__ == '__main__': 35 | unittest.main() --------------------------------------------------------------------------------