Continue to port GMG codebase.
[mediagoblin.git] / mediagoblin / media_types / image / processing.py
1 # GNU MediaGoblin -- federated, autonomous media hosting
2 # Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
3 #
4 # This program is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU Affero General Public License as published by
6 # the Free Software Foundation, either version 3 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU Affero General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
17 from __future__ import print_function
18
19 try:
20 from PIL import Image
21 except ImportError:
22 import Image
23 import os
24 import logging
25 import argparse
26
27 from mediagoblin import mg_globals as mgg
28 from mediagoblin.processing import (
29 BadMediaFail, FilenameBuilder,
30 MediaProcessor, ProcessingManager,
31 request_from_args, get_process_filename,
32 store_public, copy_original)
33 from mediagoblin.tools.exif import exif_fix_image_orientation, \
34 extract_exif, clean_exif, get_gps_data, get_useful, \
35 exif_image_needs_rotation
36
37 _log = logging.getLogger(__name__)
38
39 PIL_FILTERS = {
40 'NEAREST': Image.NEAREST,
41 'BILINEAR': Image.BILINEAR,
42 'BICUBIC': Image.BICUBIC,
43 'ANTIALIAS': Image.ANTIALIAS}
44
45 MEDIA_TYPE = 'mediagoblin.media_types.image'
46
47
48 def resize_image(entry, resized, keyname, target_name, new_size,
49 exif_tags, workdir, quality, filter):
50 """
51 Store a resized version of an image and return its pathname.
52
53 Arguments:
54 proc_state -- the processing state for the image to resize
55 resized -- an image from Image.open() of the original image being resized
56 keyname -- Under what key to save in the db.
57 target_name -- public file path for the new resized image
58 exif_tags -- EXIF data for the original image
59 workdir -- directory path for storing converted image files
60 new_size -- 2-tuple size for the resized image
61 quality -- level of compression used when resizing images
62 filter -- One of BICUBIC, BILINEAR, NEAREST, ANTIALIAS
63 """
64 resized = exif_fix_image_orientation(resized, exif_tags) # Fix orientation
65
66 try:
67 resize_filter = PIL_FILTERS[filter.upper()]
68 except KeyError:
69 raise Exception('Filter "{0}" not found, choose one of {1}'.format(
70 unicode(filter),
71 u', '.join(PIL_FILTERS.keys())))
72
73 resized.thumbnail(new_size, resize_filter)
74
75 # Copy the new file to the conversion subdir, then remotely.
76 tmp_resized_filename = os.path.join(workdir, target_name)
77 with file(tmp_resized_filename, 'w') as resized_file:
78 resized.save(resized_file, quality=quality)
79 store_public(entry, keyname, tmp_resized_filename, target_name)
80
81 # store the thumb/medium info
82 image_info = {'width': new_size[0],
83 'height': new_size[1],
84 'quality': quality,
85 'filter': filter}
86
87 entry.set_file_metadata(keyname, **image_info)
88
89
90 def resize_tool(entry,
91 force, keyname, orig_file, target_name,
92 conversions_subdir, exif_tags, quality, filter, new_size=None):
93 # Use the default size if new_size was not given
94 if not new_size:
95 max_width = mgg.global_config['media:' + keyname]['max_width']
96 max_height = mgg.global_config['media:' + keyname]['max_height']
97 new_size = (max_width, max_height)
98
99 # If thumb or medium is already the same quality and size, then don't
100 # reprocess
101 if _skip_resizing(entry, keyname, new_size, quality, filter):
102 _log.info('{0} of same size and quality already in use, skipping '
103 'resizing of media {1}.'.format(keyname, entry.id))
104 return
105
106 # If the size of the original file exceeds the specified size for the desized
107 # file, a target_name file is created and later associated with the media
108 # entry.
109 # Also created if the file needs rotation, or if forced.
110 try:
111 im = Image.open(orig_file)
112 except IOError:
113 raise BadMediaFail()
114 if force \
115 or im.size[0] > new_size[0]\
116 or im.size[1] > new_size[1]\
117 or exif_image_needs_rotation(exif_tags):
118 resize_image(
119 entry, im, unicode(keyname), target_name,
120 tuple(new_size),
121 exif_tags, conversions_subdir,
122 quality, filter)
123
124
125 def _skip_resizing(entry, keyname, size, quality, filter):
126 """
127 Determines wither the saved thumb or medium is of the same quality and size
128 """
129 image_info = entry.get_file_metadata(keyname)
130
131 if not image_info:
132 return False
133
134 skip = True
135
136 if image_info.get('width') != size[0]:
137 skip = False
138
139 elif image_info.get('height') != size[1]:
140 skip = False
141
142 elif image_info.get('filter') != filter:
143 skip = False
144
145 elif image_info.get('quality') != quality:
146 skip = False
147
148 return skip
149
150
151 SUPPORTED_FILETYPES = ['png', 'gif', 'jpg', 'jpeg', 'tiff']
152
153
154 def sniff_handler(media_file, filename):
155 _log.info('Sniffing {0}'.format(MEDIA_TYPE))
156 name, ext = os.path.splitext(filename)
157 clean_ext = ext[1:].lower() # Strip the . from ext and make lowercase
158
159 if clean_ext in SUPPORTED_FILETYPES:
160 _log.info('Found file extension in supported filetypes')
161 return MEDIA_TYPE
162 else:
163 _log.debug('Media present, extension not found in {0}'.format(
164 SUPPORTED_FILETYPES))
165
166 return None
167
168
169 class CommonImageProcessor(MediaProcessor):
170 """
171 Provides a base for various media processing steps
172 """
173 # list of acceptable file keys in order of prefrence for reprocessing
174 acceptable_files = ['original', 'medium']
175
176 def common_setup(self):
177 """
178 Set up the workbench directory and pull down the original file
179 """
180 self.image_config = mgg.global_config['plugins'][
181 'mediagoblin.media_types.image']
182
183 ## @@: Should this be two functions?
184 # Conversions subdirectory to avoid collisions
185 self.conversions_subdir = os.path.join(
186 self.workbench.dir, 'conversions')
187 os.mkdir(self.conversions_subdir)
188
189 # Pull down and set up the processing file
190 self.process_filename = get_process_filename(
191 self.entry, self.workbench, self.acceptable_files)
192 self.name_builder = FilenameBuilder(self.process_filename)
193
194 # Exif extraction
195 self.exif_tags = extract_exif(self.process_filename)
196
197 def generate_medium_if_applicable(self, size=None, quality=None,
198 filter=None):
199 if not quality:
200 quality = self.image_config['quality']
201 if not filter:
202 filter = self.image_config['resize_filter']
203
204 resize_tool(self.entry, False, 'medium', self.process_filename,
205 self.name_builder.fill('{basename}.medium{ext}'),
206 self.conversions_subdir, self.exif_tags, quality,
207 filter, size)
208
209 def generate_thumb(self, size=None, quality=None, filter=None):
210 if not quality:
211 quality = self.image_config['quality']
212 if not filter:
213 filter = self.image_config['resize_filter']
214
215 resize_tool(self.entry, True, 'thumb', self.process_filename,
216 self.name_builder.fill('{basename}.thumbnail{ext}'),
217 self.conversions_subdir, self.exif_tags, quality,
218 filter, size)
219
220 def copy_original(self):
221 copy_original(
222 self.entry, self.process_filename,
223 self.name_builder.fill('{basename}{ext}'))
224
225 def extract_metadata(self):
226 # Is there any GPS data
227 gps_data = get_gps_data(self.exif_tags)
228
229 # Insert exif data into database
230 exif_all = clean_exif(self.exif_tags)
231
232 if len(exif_all):
233 self.entry.media_data_init(exif_all=exif_all)
234
235 if len(gps_data):
236 for key in list(gps_data.keys()):
237 gps_data['gps_' + key] = gps_data.pop(key)
238 self.entry.media_data_init(**gps_data)
239
240
241 class InitialProcessor(CommonImageProcessor):
242 """
243 Initial processing step for new images
244 """
245 name = "initial"
246 description = "Initial processing"
247
248 @classmethod
249 def media_is_eligible(cls, entry=None, state=None):
250 """
251 Determine if this media type is eligible for processing
252 """
253 if not state:
254 state = entry.state
255 return state in (
256 "unprocessed", "failed")
257
258 ###############################
259 # Command line interface things
260 ###############################
261
262 @classmethod
263 def generate_parser(cls):
264 parser = argparse.ArgumentParser(
265 description=cls.description,
266 prog=cls.name)
267
268 parser.add_argument(
269 '--size',
270 nargs=2,
271 metavar=('max_width', 'max_height'),
272 type=int)
273
274 parser.add_argument(
275 '--thumb-size',
276 nargs=2,
277 metavar=('max_width', 'max_height'),
278 type=int)
279
280 parser.add_argument(
281 '--filter',
282 choices=['BICUBIC', 'BILINEAR', 'NEAREST', 'ANTIALIAS'])
283
284 parser.add_argument(
285 '--quality',
286 type=int,
287 help='level of compression used when resizing images')
288
289 return parser
290
291 @classmethod
292 def args_to_request(cls, args):
293 return request_from_args(
294 args, ['size', 'thumb_size', 'filter', 'quality'])
295
296 def process(self, size=None, thumb_size=None, quality=None, filter=None):
297 self.common_setup()
298 self.generate_medium_if_applicable(size=size, filter=filter,
299 quality=quality)
300 self.generate_thumb(size=thumb_size, filter=filter, quality=quality)
301 self.copy_original()
302 self.extract_metadata()
303 self.delete_queue_file()
304
305
306 class Resizer(CommonImageProcessor):
307 """
308 Resizing process steps for processed media
309 """
310 name = 'resize'
311 description = 'Resize image'
312 thumb_size = 'size'
313
314 @classmethod
315 def media_is_eligible(cls, entry=None, state=None):
316 """
317 Determine if this media type is eligible for processing
318 """
319 if not state:
320 state = entry.state
321 return state in 'processed'
322
323 ###############################
324 # Command line interface things
325 ###############################
326
327 @classmethod
328 def generate_parser(cls):
329 parser = argparse.ArgumentParser(
330 description=cls.description,
331 prog=cls.name)
332
333 parser.add_argument(
334 '--size',
335 nargs=2,
336 metavar=('max_width', 'max_height'),
337 type=int)
338
339 parser.add_argument(
340 '--filter',
341 choices=['BICUBIC', 'BILINEAR', 'NEAREST', 'ANTIALIAS'])
342
343 parser.add_argument(
344 '--quality',
345 type=int,
346 help='level of compression used when resizing images')
347
348 parser.add_argument(
349 'file',
350 choices=['medium', 'thumb'])
351
352 return parser
353
354 @classmethod
355 def args_to_request(cls, args):
356 return request_from_args(
357 args, ['size', 'file', 'quality', 'filter'])
358
359 def process(self, file, size=None, filter=None, quality=None):
360 self.common_setup()
361 if file == 'medium':
362 self.generate_medium_if_applicable(size=size, filter=filter,
363 quality=quality)
364 elif file == 'thumb':
365 self.generate_thumb(size=size, filter=filter, quality=quality)
366
367
368 class ImageProcessingManager(ProcessingManager):
369 def __init__(self):
370 super(self.__class__, self).__init__()
371 self.add_processor(InitialProcessor)
372 self.add_processor(Resizer)
373
374
375 if __name__ == '__main__':
376 import sys
377 import pprint
378
379 pp = pprint.PrettyPrinter()
380
381 result = extract_exif(sys.argv[1])
382 gps = get_gps_data(result)
383 clean = clean_exif(result)
384 useful = get_useful(clean)
385
386 print(pp.pprint(clean))