Continue to port GMG codebase.
[mediagoblin.git] / mediagoblin / media_types / image / processing.py
CommitLineData
93bdab9d 1# GNU MediaGoblin -- federated, autonomous media hosting
cf29e8a8 2# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
93bdab9d
JW
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
7f342c72
BP
17from __future__ import print_function
18
d0e9f843
AL
19try:
20 from PIL import Image
21except ImportError:
22 import Image
8e5f9746 23import os
92f129b5 24import logging
7ac66a3d 25import argparse
93bdab9d 26
93bdab9d 27from mediagoblin import mg_globals as mgg
85ead8ac
CAW
28from mediagoblin.processing import (
29 BadMediaFail, FilenameBuilder,
d1e9913b 30 MediaProcessor, ProcessingManager,
1cefccc7 31 request_from_args, get_process_filename,
5fd239fa 32 store_public, copy_original)
a180ca26 33from mediagoblin.tools.exif import exif_fix_image_orientation, \
0f8221dc
B
34 extract_exif, clean_exif, get_gps_data, get_useful, \
35 exif_image_needs_rotation
93bdab9d 36
92f129b5
JW
37_log = logging.getLogger(__name__)
38
d63cc34e
JW
39PIL_FILTERS = {
40 'NEAREST': Image.NEAREST,
41 'BILINEAR': Image.BILINEAR,
42 'BICUBIC': Image.BICUBIC,
43 'ANTIALIAS': Image.ANTIALIAS}
44
58a94757
RE
45MEDIA_TYPE = 'mediagoblin.media_types.image'
46
deea3f66 47
5fd239fa 48def resize_image(entry, resized, keyname, target_name, new_size,
a2f50198 49 exif_tags, workdir, quality, filter):
c72d661b
JW
50 """
51 Store a resized version of an image and return its pathname.
063670e9
BS
52
53 Arguments:
c82a8ba5 54 proc_state -- the processing state for the image to resize
3b359ddd 55 resized -- an image from Image.open() of the original image being resized
8f88b1f6
E
56 keyname -- Under what key to save in the db.
57 target_name -- public file path for the new resized image
063670e9
BS
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
a2f50198
RE
61 quality -- level of compression used when resizing images
62 filter -- One of BICUBIC, BILINEAR, NEAREST, ANTIALIAS
063670e9 63 """
063670e9 64 resized = exif_fix_image_orientation(resized, exif_tags) # Fix orientation
7cd7db5a 65
7cd7db5a 66 try:
a2f50198 67 resize_filter = PIL_FILTERS[filter.upper()]
7cd7db5a
JW
68 except KeyError:
69 raise Exception('Filter "{0}" not found, choose one of {1}'.format(
a2f50198 70 unicode(filter),
d63cc34e 71 u', '.join(PIL_FILTERS.keys())))
7cd7db5a
JW
72
73 resized.thumbnail(new_size, resize_filter)
063670e9 74
063670e9 75 # Copy the new file to the conversion subdir, then remotely.
8f88b1f6 76 tmp_resized_filename = os.path.join(workdir, target_name)
063670e9 77 with file(tmp_resized_filename, 'w') as resized_file:
a2f50198 78 resized.save(resized_file, quality=quality)
5fd239fa 79 store_public(entry, keyname, tmp_resized_filename, target_name)
063670e9 80
e2b56345
RE
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
deea3f66 89
5b546d65 90def resize_tool(entry,
af51c423 91 force, keyname, orig_file, target_name,
a2f50198 92 conversions_subdir, exif_tags, quality, filter, new_size=None):
3e9faf85 93 # Use the default size if new_size was not given
9a2c66ca
RE
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)
3e9faf85 98
e2b56345
RE
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
3b359ddd
E
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:
af51c423 111 im = Image.open(orig_file)
3b359ddd
E
112 except IOError:
113 raise BadMediaFail()
114 if force \
49db7785
RE
115 or im.size[0] > new_size[0]\
116 or im.size[1] > new_size[1]\
3b359ddd 117 or exif_image_needs_rotation(exif_tags):
3b359ddd 118 resize_image(
5fd239fa 119 entry, im, unicode(keyname), target_name,
931fa43f 120 tuple(new_size),
a2f50198
RE
121 exif_tags, conversions_subdir,
122 quality, filter)
3b359ddd
E
123
124
e2b56345
RE
125def _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
b1a763f6 151SUPPORTED_FILETYPES = ['png', 'gif', 'jpg', 'jpeg', 'tiff']
92f129b5 152
c56d4b55 153
301da9ca 154def sniff_handler(media_file, filename):
58a94757 155 _log.info('Sniffing {0}'.format(MEDIA_TYPE))
301da9ca
CAW
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
92f129b5 162 else:
301da9ca
CAW
163 _log.debug('Media present, extension not found in {0}'.format(
164 SUPPORTED_FILETYPES))
92f129b5 165
58a94757 166 return None
ec4261a4 167
c56d4b55 168
85ead8ac
CAW
169class CommonImageProcessor(MediaProcessor):
170 """
171 Provides a base for various media processing steps
172 """
1cefccc7
RE
173 # list of acceptable file keys in order of prefrence for reprocessing
174 acceptable_files = ['original', 'medium']
175
5fd239fa
CAW
176 def common_setup(self):
177 """
178 Set up the workbench directory and pull down the original file
179 """
93874d0a
RE
180 self.image_config = mgg.global_config['plugins'][
181 'mediagoblin.media_types.image']
a2f50198 182
5fd239fa 183 ## @@: Should this be two functions?
eb372949
CAW
184 # Conversions subdirectory to avoid collisions
185 self.conversions_subdir = os.path.join(
0485e9c8 186 self.workbench.dir, 'conversions')
5fd239fa 187 os.mkdir(self.conversions_subdir)
eb372949 188
1cefccc7
RE
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)
2fa7b7f8 193
5b546d65 194 # Exif extraction
1cefccc7 195 self.exif_tags = extract_exif(self.process_filename)
5b546d65 196
a2f50198
RE
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
1cefccc7 204 resize_tool(self.entry, False, 'medium', self.process_filename,
5fd239fa 205 self.name_builder.fill('{basename}.medium{ext}'),
a2f50198
RE
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:
63021eb6 213 filter = self.image_config['resize_filter']
2fa7b7f8 214
1cefccc7 215 resize_tool(self.entry, True, 'thumb', self.process_filename,
5fd239fa 216 self.name_builder.fill('{basename}.thumbnail{ext}'),
a2f50198
RE
217 self.conversions_subdir, self.exif_tags, quality,
218 filter, size)
5fd239fa
CAW
219
220 def copy_original(self):
221 copy_original(
1cefccc7 222 self.entry, self.process_filename,
5fd239fa 223 self.name_builder.fill('{basename}{ext}'))
2fa7b7f8
CAW
224
225 def extract_metadata(self):
5fd239fa 226 # Is there any GPS data
5b546d65 227 gps_data = get_gps_data(self.exif_tags)
5fd239fa
CAW
228
229 # Insert exif data into database
5b546d65 230 exif_all = clean_exif(self.exif_tags)
5fd239fa
CAW
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)
2fa7b7f8 239
85ead8ac
CAW
240
241class InitialProcessor(CommonImageProcessor):
242 """
243 Initial processing step for new images
244 """
245 name = "initial"
246 description = "Initial processing"
247
248 @classmethod
7584080b 249 def media_is_eligible(cls, entry=None, state=None):
85ead8ac
CAW
250 """
251 Determine if this media type is eligible for processing
252 """
7584080b
RE
253 if not state:
254 state = entry.state
255 return state in (
85ead8ac
CAW
256 "unprocessed", "failed")
257
258 ###############################
259 # Command line interface things
260 ###############################
261
262 @classmethod
55a10fef 263 def generate_parser(cls):
85ead8ac 264 parser = argparse.ArgumentParser(
55a10fef
CAW
265 description=cls.description,
266 prog=cls.name)
85ead8ac 267
5b546d65
CAW
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,
58350141 277 metavar=('max_width', 'max_height'),
5b546d65 278 type=int)
85ead8ac 279
a2f50198
RE
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
85ead8ac
CAW
289 return parser
290
291 @classmethod
55a10fef 292 def args_to_request(cls, args):
d1e9913b 293 return request_from_args(
a2f50198 294 args, ['size', 'thumb_size', 'filter', 'quality'])
85ead8ac 295
a2f50198 296 def process(self, size=None, thumb_size=None, quality=None, filter=None):
5fd239fa 297 self.common_setup()
a2f50198
RE
298 self.generate_medium_if_applicable(size=size, filter=filter,
299 quality=quality)
63021eb6 300 self.generate_thumb(size=thumb_size, filter=filter, quality=quality)
916db96e 301 self.copy_original()
2fa7b7f8 302 self.extract_metadata()
58350141
RE
303 self.delete_queue_file()
304
305
306class Resizer(CommonImageProcessor):
307 """
308 Resizing process steps for processed media
309 """
310 name = 'resize'
311 description = 'Resize image'
3225008f 312 thumb_size = 'size'
58350141
RE
313
314 @classmethod
7584080b 315 def media_is_eligible(cls, entry=None, state=None):
58350141
RE
316 """
317 Determine if this media type is eligible for processing
318 """
7584080b
RE
319 if not state:
320 state = entry.state
321 return state in 'processed'
58350141
RE
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
a2f50198
RE
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
58350141
RE
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(
a2f50198 357 args, ['size', 'file', 'quality', 'filter'])
58350141 358
a2f50198 359 def process(self, file, size=None, filter=None, quality=None):
58350141
RE
360 self.common_setup()
361 if file == 'medium':
a2f50198
RE
362 self.generate_medium_if_applicable(size=size, filter=filter,
363 quality=quality)
58350141 364 elif file == 'thumb':
a2f50198 365 self.generate_thumb(size=size, filter=filter, quality=quality)
2fa7b7f8 366
85ead8ac
CAW
367
368class ImageProcessingManager(ProcessingManager):
369 def __init__(self):
370 super(self.__class__, self).__init__()
371 self.add_processor(InitialProcessor)
58350141 372 self.add_processor(Resizer)
85ead8ac
CAW
373
374
e8e444a8
JW
375if __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)
a180ca26
JW
383 clean = clean_exif(result)
384 useful = get_useful(clean)
e8e444a8 385
7f342c72 386 print(pp.pprint(clean))