├── README.md ├── box.jpg ├── meter.jpg ├── minim.jpg ├── read.jpg └── read_minim.py /README.md: -------------------------------------------------------------------------------- 1 | # Reading the GEO Minim energy meter with a webcam & Python 2 | 3 | ![minim](minim.jpg) 4 | 5 | Unfortunately the GEO Minim energy meter doesn't allow you to easily fetch the 6 | data from meter with a computer. This is bad because it means we can't easily 7 | log the data or do [anything](http://cursivedata.co.uk) [interesting](https://github.com/mattvenn/energy-wristband) with it. 8 | 9 | I was in a position where I really wanted to do energy logging with one of 10 | these meters. Here is a Python program that takes a photo, processes it, then 11 | reads the 'speedometer' bar graph from the meter, and returns the current energy 12 | usage in Watts. 13 | 14 | # Setup (and computer vision tips) 15 | 16 | ## Build your enclosure 17 | 18 | ![enclosure](box.jpg) 19 | 20 | It's super important to control the photo environment of the meter, webcam and 21 | light. This makes it a lot easier to do the image processing afterwards. And in 22 | my limited experience, the easier that is the better! 23 | 24 | I ended up also adding some crumpled silver foil and some translucent plastic to 25 | try to minimize glare from the light. 26 | 27 | Also make sure that the meter, light and camera are securely fixed in the box. 28 | We're keeping it simple and relying on the meter's position in the box not 29 | changing between shots. 30 | 31 | ## Run the program 32 | 33 | * Install the requirements (below) 34 | * Connect the webcam 35 | * Run the program `python read_minim.py` 36 | * Tune the program till it works with your enclosure, light and webcam. 37 | 38 | ## Photos and processing 39 | 40 | Here's the raw photo that I get (saved as meter.jpg by the program) 41 | 42 | ![meter](meter.jpg) 43 | 44 | After the processing, (if successful) you'll have an image that shows the result 45 | of the image processing: 46 | 47 | ![processed](read.jpg) 48 | 49 | The black dots show where the program thinks the bar is lit, and the white ones 50 | are where it thinks the bar is not lit. 51 | 52 | If this doesn't work, you'll need to do some tuning... 53 | 54 | ## Tuning 55 | 56 | Try changing the `sens` variable (default 20). This sets the difference in 57 | brightness needed for the program to think the bar graph segment has gone from 58 | lit to non lit. 59 | 60 | Try changing the exposure and brightness settings in the webcam function 61 | `take_photo()` 62 | 63 | Change the log level line 64 | 65 | logging.basicConfig(level=logging.INFO) 66 | 67 | from INFO to DEBUG to get lots more debugging info 68 | 69 | # Requirements 70 | 71 | * easyprocess Python package 72 | * PIL Python package 73 | * fswebcam 74 | 75 | # Notes & Resources 76 | 77 | * [GEO Minim Manual](http://www.greenenergyoptions.co.uk/assets/media/instruction-manuals/geominim.pdf) 78 | * [Teardown of the minim](http://diary.piku.org.uk/2009/12/03/british-gas-energysmart-energy-meter-teardown/) 79 | * A [program that can read a 7 segment display](https://www.unix-ag.uni-kl.de/~auerswal/ssocr/) - didn't work for me 80 | * Interesting [paper](http://www.ski.org/Rehab/HShen/Publications/embedded.pdf) about reading 7 segment displays with a cell phone 81 | -------------------------------------------------------------------------------- /box.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattvenn/minim-reader/34046d3fa6c20c6f6e651ff849eaf3facd907775/box.jpg -------------------------------------------------------------------------------- /meter.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattvenn/minim-reader/34046d3fa6c20c6f6e651ff849eaf3facd907775/meter.jpg -------------------------------------------------------------------------------- /minim.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattvenn/minim-reader/34046d3fa6c20c6f6e651ff849eaf3facd907775/minim.jpg -------------------------------------------------------------------------------- /read.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattvenn/minim-reader/34046d3fa6c20c6f6e651ff849eaf3facd907775/read.jpg -------------------------------------------------------------------------------- /read_minim.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | from PIL import Image, ImageDraw,ImageStat,ImageEnhance 4 | from easyprocess import Proc 5 | import math 6 | import os 7 | import time 8 | 9 | # the change in brightness needed to register the end of the bar graph 10 | # might need adjusting 11 | sens = 20 12 | 13 | # where we store the raw photo of the meter 14 | image_file = "meter.jpg" 15 | 16 | # where we store the output photo 17 | processed_file = "read.jpg" 18 | 19 | # taken from the minim instructions 20 | e_map = [ 10, 50, 100, 150, 200, 250, 350, 450, 550, 650, 750, 950, 1150, 1350, 1550, 2000, 2500, 3000, 3500, 4000, 4500, 5500, 6500, 7500, 8500, 10000, 12000, 14000, 16000, 18000, ] 21 | 22 | class Meter_Exception(Exception): 23 | def __init__(self, message): 24 | super(Meter_Exception, self).__init__(message) 25 | self.message = message 26 | 27 | # take a photo with a timeout 28 | def take_photo(timeout,logger): 29 | # will almost certainly need adjusting for your setup 30 | cmd = '/usr/bin/fswebcam -q -d /dev/video0 -r 800x600 --no-banner --set "Exposure, Auto"="Manual Mode" --set "Exposure (Absolute)"=200 --set brightness=50% --set "Exposure, Auto Priority"="False" ' + image_file 31 | proc=Proc(cmd).call(timeout=timeout) 32 | if proc.stderr: 33 | logger.warning(proc.stderr) 34 | return proc.return_code 35 | 36 | # crop and adjust contrast 37 | def adjust(im): 38 | im=im.convert('L') 39 | w = 500 40 | h = 350 41 | box = (80,60,w,h) 42 | region = im.crop(box) 43 | contr = ImageEnhance.Contrast(region) 44 | region = contr.enhance(2.0) 45 | return region 46 | 47 | # returns the average value of a region of pixels 48 | def avg_region(image,box): 49 | region = image.crop(box) 50 | stat = ImageStat.Stat(region) 51 | return stat.mean[0] 52 | 53 | # read energy from a prepared image of the meter 54 | # this works by looking at the circular bar graph 55 | # and looking for big changes in image brightness 56 | def read_energy(img,logger): 57 | draw = ImageDraw.Draw(img) 58 | img_width = img.size[0] 59 | img_height = img.size[1] 60 | sample_w = 7 # size of a block of pixels to look at 61 | cent_x = img_width / 2 62 | cent_y = img_height * 0.71 63 | length = img_width / 2.8 # radius of bar graph 64 | 65 | # half length of bar graph in degrees 66 | arc_l = 104 67 | segment = 1 68 | arc_step = 2 69 | d = -arc_l 70 | segs = 0 71 | last_bright = 255 72 | fill = 0 73 | change = False 74 | while d < arc_l: 75 | segs += 1 76 | d += arc_step 77 | # each segment in the bar is a bit bigger than the last 78 | arc_step += 0.37 79 | deg = d - 90 80 | x = cent_x + length * math.cos(math.radians(deg)) 81 | y = cent_y + length * math.sin(math.radians(deg)) 82 | x = int(x) 83 | y = int(y) 84 | 85 | # create a region over where we think the bar segment will be 86 | box = (x, y, x+sample_w, y+sample_w) 87 | bright = avg_region(img,box) 88 | logger.debug( "lb = %d, b= %d" % ( bright - last_bright, bright)) 89 | if change == False and (bright - last_bright ) > sens: 90 | logger.debug( e_map[segs] ) 91 | segment = segs 92 | fill = 255 93 | change = True 94 | draw.rectangle(box,fill=fill) 95 | 96 | # adapt to changing brightness 97 | last_bright = bright 98 | 99 | segment -= 1 # because list is 0 indexed 100 | logger.debug("total segs=%d" % segs) 101 | logger.debug("segment=%d energy=%dW" % (segment, e_map[segment])) 102 | img.save(processed_file) 103 | return(e_map[segment]) 104 | 105 | 106 | def read_meter(logger, timeout=10): 107 | # remove existing image to guarantee we don't use an old image 108 | try: 109 | os.remove(image_file) 110 | except OSError: 111 | pass 112 | logger.debug("taking photo with timeout = %d", timeout) 113 | ret = take_photo(timeout,logger) 114 | if ret == -15: 115 | raise Meter_Exception("photo timed out") 116 | logger.debug("took photo") 117 | 118 | # check we have an image 119 | try: 120 | img = Image.open(image_file) 121 | except IOError: 122 | raise Meter_Exception("no photo taken") 123 | 124 | # adjust image - crop and contrast 125 | img = adjust(img) 126 | 127 | # read the bar graph 128 | energy = read_energy(img,logger) 129 | return energy 130 | 131 | if __name__ == '__main__': 132 | import logging 133 | logging.basicConfig(level=logging.INFO) 134 | logging.info("energy=%dW" % read_meter(logging)) 135 | --------------------------------------------------------------------------------