├── README.md ├── app ├── ImageResizingServerApp.py └── server.conf ├── nginx-conf └── ImageResizingServer ├── setup.py └── uwsgi-conf └── ImageResizingServerApp.ini /README.md: -------------------------------------------------------------------------------- 1 | Image Resizing Server 2 | ================== 3 | 4 | This server provides a service to resize and crop images with a simple API. It's able to hold heavy loads and is easy to use. 5 | 6 | It was written in Python, using Tornado web framework, Uwsgi to distribute the application on the network and Nginx front-end. 7 | 8 | I want to specially thanks Stéphane Bunel (https://bitbucket.org/StephaneBunel), he was the initiator of this project, and advised me on how to re-develop this project for open-sourcing. 9 | 10 | It's my first open-source project, feel free to contribute. 11 | 12 | Installation 13 | ----------- 14 | 15 | need sudo : 16 | ```bash 17 | apt-get install python-pip python-imaging nginx build-essential python-dev libxml2-dev && pip install uwsgi tornado 18 | cd /tmp && git clone https://github.com/noony/ImageResizingServer.git && cd ./ImageResizingServer 19 | python setup.py install 20 | ``` 21 | Attention if you haven't installed nginx you have to remove default conf in sites-enabled (rm /etc/nginx/sites-enabled/default) 22 | 23 | Configuration 24 | ----------- 25 | 26 | You have to define your image clusters. It a simple dict in server.conf 27 | 28 | default : 29 | 30 | clusterInfos = { 31 | } 32 | signatureSecret = "" 33 | timeoutGetCluster = 1 34 | defaultQuality = 90 35 | minHeight = 1 36 | maxHeight = 2048 37 | minWidth = 1 38 | maxWidth = 2048 39 | 40 | 41 | Example : 42 | 43 | clusterInfos = { 44 | 'cluster1': 'url.cluster1.com', 45 | 'cluster2': 'url.cluster2.com' 46 | } 47 | signatureSecret = "MY_SECRET_TOKEN" 48 | timeoutGetCluster = 1 49 | defaultQuality = 100 50 | minHeight = 1 51 | maxHeight = 1024 52 | minWidth = 1 53 | maxWidth = 1024 54 | 55 | You have to restart uwsgi after. 56 | 57 | Examples 58 | ----------- 59 | Resize an image to 100px width : 60 | 61 | http://example.com/cluster1/100x0/path/to/image.png 62 | 63 | Resize an image to 300px height : 64 | 65 | http://example.com/cluster1/0x300/path/to/image.png 66 | 67 | Resize an image to 600px width and change is quality to 60% : 68 | 69 | http://example.com/cluster1/60/600x0/path/to/image.png 70 | 71 | Crop an image and resize 200/200px : 72 | 73 | http://example.com/cluster1/crop/200x200/path/to/image.png 74 | 75 | Crop an image and resize 200/200px and change quality to 95% : 76 | 77 | http://example.com/cluster1/crop/95/200x200/path/to/image.png 78 | 79 | Securise your application 80 | ----------- 81 | If you want to securise the application, put a token in configuration directive "signatureSecret". 82 | 83 | And after to retrieve the signature you just have to follow this example: 84 | 85 | configuration: signatureSecret="YOUR_SECRET_TOKEN" 86 | 87 | uri: /cluster1/crop/95/200x200/path/to/image.png 88 | 89 | Python : 90 | ```python 91 | import hashlib 92 | hashlib.sha512('YOUR_SECRET_TOKEN' + '/cluster1/crop/95/200x200/path/to/image.png').hexdigest()[:4] 93 | result : '61b5' 94 | ``` 95 | PHP : 96 | ```php 97 | substr(hash('sha512', 'YOUR_SECRET_TOKEN' . '/cluster1/crop/95/200x200/path/to/image.png'), 0, 4); 98 | result : '61b5' 99 | ``` 100 | 101 | Final uri : /61b5/cluster1/crop/95/200x200/path/to/image.png 102 | -------------------------------------------------------------------------------- /app/ImageResizingServerApp.py: -------------------------------------------------------------------------------- 1 | try: 2 | from PIL import Image 3 | except ImportError: 4 | import Image 5 | 6 | import os 7 | import re 8 | import sys 9 | import time 10 | import signal 11 | import logging 12 | import httplib 13 | import hashlib 14 | import StringIO 15 | 16 | import tornado.web 17 | import tornado.wsgi 18 | import tornado.escape 19 | from tornado.options import define, options 20 | 21 | define("clusterInfos", default={}, help="url of img cluster", type=dict) 22 | define( 23 | "signatureSecret", default="", help="add signature to request", type=str) 24 | define("defaultQuality", default=90, help="default output quality", type=int) 25 | define("minHeight", default=1, help="minimum height after resize", type=int) 26 | define("maxHeight", default=2048, help="maximum height after resize", type=int) 27 | define("minWidth", default=1, help="minimum width after resize", type=int) 28 | define("maxWidth", default=2048, help="maximum width after resize", type=int) 29 | define("timeoutGetCluster", default=1, 30 | help="timeout for get image on cluster", type=int) 31 | options.parse_config_file('./server.conf') 32 | 33 | LOG = logging.getLogger(__name__) 34 | LOG.setLevel(logging.ERROR) 35 | 36 | for name in options.clusterInfos: 37 | if len(name) == 4: 38 | LOG.error( 39 | 'You can\'t have a cluster name which have a length of 4, because it\'s in conflict with signature.') 40 | exit(1) 41 | 42 | 43 | class ResizerHandler(tornado.web.RequestHandler): 44 | pilImage = None 45 | imgUrl = None 46 | cluster = None 47 | format = None 48 | crop = False 49 | quality = 90 50 | newHeight = 0 51 | newWidth = 0 52 | originalWidth = 0 53 | originalHeight = 0 54 | 55 | def get(self, signature, cluster, crop, quality, width, height, imgUrl): 56 | self.checkParams( 57 | signature, cluster, crop, quality, width, height, imgUrl) 58 | self.loadImageFromCluster() 59 | 60 | if self.crop: 61 | cropRatio = float(self.newHeight) / self.newWidth 62 | ratio = float(self.originalWidth) / self.originalHeight 63 | 64 | if cropRatio > ratio: 65 | cropW = self.originalWidth 66 | cropH = int(self.originalWidth / cropRatio) or 1 67 | else: 68 | cropH = self.originalHeight 69 | cropW = int(cropRatio * self.originalHeight) or 1 70 | 71 | cropX = int(0.5 * (self.originalWidth - cropW)) 72 | cropY = int(0.5 * (self.originalHeight - cropH)) 73 | 74 | self.cropImage(cropX, cropY, cropW, cropH) 75 | self.resizeImage() 76 | else: 77 | if self.newWidth + self.newHeight == 0: 78 | pass 79 | elif self.newWidth == self.originalWidth and self.newHeight == 0: 80 | pass 81 | elif self.newHeight == self.originalHeight and self.newWidth == 0: 82 | pass 83 | elif self.newWidth > 0 and self.newHeight == 0: 84 | ratio = float(self.newWidth) / self.originalWidth 85 | self.newHeight = int(ratio * self.originalHeight) or 1 86 | self.resizeImage() 87 | elif self.newHeight > 0 and self.newWidth == 0: 88 | ratio = float(self.newHeight) / self.originalHeight 89 | self.newWidth = int(ratio * self.originalWidth) or 1 90 | self.resizeImage() 91 | else: 92 | self.resizeImage() 93 | 94 | image = StringIO.StringIO() 95 | 96 | try: 97 | self.pilImage.save(image, self.format, quality=self.quality) 98 | self.set_header('Content-Type', 'image/' + self.format.lower()) 99 | self.write(image.getvalue()) 100 | except: 101 | msg = 'Finish Request Error {0}'.format(sys.exc_info()[1]) 102 | LOG.error(msg) 103 | raise tornado.web.HTTPError(500, msg) 104 | 105 | def checkParams(self, signature, cluster, crop, quality, width, height, imgUrl): 106 | self.imgUrl = '/' + imgUrl 107 | self.newHeight = int(height) 108 | self.newWidth = int(width) 109 | self.cluster = cluster 110 | 111 | if options.signatureSecret is not "" and (signature is None or signature[:4] != hashlib.sha512(options.signatureSecret + self.request.uri[5:]).hexdigest()[:4]): 112 | raise tornado.web.HTTPError(403, 'Bad signature') 113 | 114 | if self.cluster not in options.clusterInfos: 115 | raise tornado.web.HTTPError( 116 | 400, 'Bad argument Cluster : cluster {0} not found in configuration'.format(self.cluster)) 117 | 118 | if self.newHeight == 0 and self.newWidth == 0: 119 | raise tornado.web.HTTPError( 120 | 400, 'Bad argument Height and Width can\'t be both at 0') 121 | 122 | if self.newHeight != 0: 123 | if self.newHeight < options.minHeight or self.newHeight > options.maxHeight: 124 | raise tornado.web.HTTPError( 125 | 400, 'Bad argument Height : {0}>=h<{1}'.format(options.minHeight, options.maxHeight)) 126 | 127 | if self.newWidth != 0: 128 | if self.newWidth < options.minWidth or self.newWidth > options.maxWidth: 129 | raise tornado.web.HTTPError( 130 | 400, 'Bad argument Width : {0}>=w<{1}'.format(options.minWidth, options.maxWidth)) 131 | 132 | if quality is not None: 133 | self.quality = int(re.match(r'\d+', quality).group()) 134 | else: 135 | self.quality = options.defaultQuality 136 | 137 | if self.quality <= 0 or self.quality > 100: 138 | raise tornado.web.HTTPError(400, 'Bad argument Quality : 0>q<100') 139 | 140 | if crop is not None: 141 | self.crop = True 142 | if self.newWidth == 0 or self.newHeight == 0: 143 | raise tornado.web.HTTPError( 144 | 400, 'Crop error, you have to sprecify both Width ({0}) and Height ({1})'.format(self.newWidth, self.newHeight)) 145 | 146 | return True 147 | 148 | def loadImageFromCluster(self): 149 | link = httplib.HTTPConnection( 150 | options.clusterInfos.get(self.cluster), timeout=options.timeoutGetCluster) 151 | link.request('GET', self.imgUrl) 152 | resp = link.getresponse() 153 | 154 | status = resp.status 155 | 156 | if status == httplib.OK: 157 | content_type = resp.getheader('Content-Type') 158 | if content_type.startswith('image'): 159 | content = resp.read() 160 | else: 161 | raise tornado.web.HTTPError( 162 | 415, 'Bad Content type : {0}'.format(content_type)) 163 | else: 164 | msg = 'Image not found on cluster {0}'.format(self.cluster) 165 | LOG.error(msg) 166 | raise tornado.web.HTTPError(404, msg) 167 | 168 | link.close() 169 | content = StringIO.StringIO(content) 170 | 171 | try: 172 | self.pilImage = Image.open(content) 173 | self.pilImage.load() 174 | except: 175 | msg = 'Make PIL Image Error {0}'.format(sys.exc_info()[1]) 176 | LOG.error(msg) 177 | raise tornado.web.HTTPError(415, msg) 178 | 179 | self.originalWidth, self.originalHeight = self.pilImage.size 180 | self.format = self.pilImage.format 181 | 182 | return True 183 | 184 | def resizeImage(self): 185 | try: 186 | newImg = self.pilImage.resize( 187 | (self.newWidth, self.newHeight), Image.ANTIALIAS) 188 | except: 189 | msg = 'Resize Error {0}'.format(sys.exc_info()[1]) 190 | LOG.error(msg) 191 | raise tornado.web.HTTPError(500, msg) 192 | 193 | self.pilImage = newImg 194 | return True 195 | 196 | def cropImage(self, cropX, cropY, cropW, cropH): 197 | try: 198 | newImg = self.pilImage.crop( 199 | (cropX, cropY, (cropX + cropW), (cropY + cropH))) 200 | except: 201 | msg = 'Crop Error {0}'.format(sys.exc_info()[1]) 202 | LOG.error(msg) 203 | raise tornado.web.HTTPError(500, msg) 204 | 205 | self.pilImage = newImg 206 | 207 | def write_error(self, status_code, **kwargs): 208 | if "exc_info" in kwargs: 209 | self.finish("