Refactor processing/reprocessing functions into ProcessImage class
[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 try:
18 from PIL import Image
19 except ImportError:
20 import Image
21 import os
22 import logging
23 import argparse
24
25 from mediagoblin import mg_globals as mgg
26 from mediagoblin.db.models import MediaEntry
27 from mediagoblin.processing import BadMediaFail, FilenameBuilder
28 from mediagoblin.submit.lib import run_process_media
29 from mediagoblin.tools.exif import exif_fix_image_orientation, \
30 extract_exif, clean_exif, get_gps_data, get_useful, \
31 exif_image_needs_rotation
32 from mediagoblin.tools.translate import lazy_pass_to_ugettext as _
33
34 _log = logging.getLogger(__name__)
35
36 PIL_FILTERS = {
37 'NEAREST': Image.NEAREST,
38 'BILINEAR': Image.BILINEAR,
39 'BICUBIC': Image.BICUBIC,
40 'ANTIALIAS': Image.ANTIALIAS}
41
42 MEDIA_TYPE = 'mediagoblin.media_types.image'
43
44
45 def resize_image(proc_state, resized, keyname, target_name, new_size,
46 exif_tags, workdir):
47 """
48 Store a resized version of an image and return its pathname.
49
50 Arguments:
51 proc_state -- the processing state for the image to resize
52 resized -- an image from Image.open() of the original image being resized
53 keyname -- Under what key to save in the db.
54 target_name -- public file path for the new resized image
55 exif_tags -- EXIF data for the original image
56 workdir -- directory path for storing converted image files
57 new_size -- 2-tuple size for the resized image
58 """
59 config = mgg.global_config['media_type:mediagoblin.media_types.image']
60
61 resized = exif_fix_image_orientation(resized, exif_tags) # Fix orientation
62
63 filter_config = config['resize_filter']
64 try:
65 resize_filter = PIL_FILTERS[filter_config.upper()]
66 except KeyError:
67 raise Exception('Filter "{0}" not found, choose one of {1}'.format(
68 unicode(filter_config),
69 u', '.join(PIL_FILTERS.keys())))
70
71 resized.thumbnail(new_size, resize_filter)
72
73 # Copy the new file to the conversion subdir, then remotely.
74 tmp_resized_filename = os.path.join(workdir, target_name)
75 with file(tmp_resized_filename, 'w') as resized_file:
76 resized.save(resized_file, quality=config['quality'])
77 proc_state.store_public(keyname, tmp_resized_filename, target_name)
78
79
80 def resize_tool(proc_state, force, keyname, target_name,
81 conversions_subdir, exif_tags, new_size=None):
82 # filename -- the filename of the original image being resized
83 filename = proc_state.get_orig_filename()
84
85 # Use the default size if new_size was not given
86 if not new_size:
87 max_width = mgg.global_config['media:' + keyname]['max_width']
88 max_height = mgg.global_config['media:' + keyname]['max_height']
89 new_size = (max_width, max_height)
90
91 # If the size of the original file exceeds the specified size for the desized
92 # file, a target_name file is created and later associated with the media
93 # entry.
94 # Also created if the file needs rotation, or if forced.
95 try:
96 im = Image.open(filename)
97 except IOError:
98 raise BadMediaFail()
99 if force \
100 or im.size[0] > new_size[0]\
101 or im.size[1] > new_size[1]\
102 or exif_image_needs_rotation(exif_tags):
103 resize_image(
104 proc_state, im, unicode(keyname), target_name,
105 new_size,
106 exif_tags, conversions_subdir)
107
108
109 SUPPORTED_FILETYPES = ['png', 'gif', 'jpg', 'jpeg', 'tiff']
110
111
112 def sniff_handler(media_file, **kw):
113 _log.info('Sniffing {0}'.format(MEDIA_TYPE))
114 if kw.get('media') is not None: # That's a double negative!
115 name, ext = os.path.splitext(kw['media'].filename)
116 clean_ext = ext[1:].lower() # Strip the . from ext and make lowercase
117
118 if clean_ext in SUPPORTED_FILETYPES:
119 _log.info('Found file extension in supported filetypes')
120 return MEDIA_TYPE
121 else:
122 _log.debug('Media present, extension not found in {0}'.format(
123 SUPPORTED_FILETYPES))
124 else:
125 _log.warning('Need additional information (keyword argument \'media\')'
126 ' to be able to handle sniffing')
127
128 return None
129
130
131 class ProcessImage(object):
132 """Code to process an image. Will be run by celery.
133
134 A Workbench() represents a local tempory dir. It is automatically
135 cleaned up when this function exits.
136 """
137 def init(self, proc_state):
138 self.proc_state = proc_state
139 self.entry = proc_state.entry
140 self.workbench = proc_state.workbench
141
142 # Conversions subdirectory to avoid collisions
143 self.conversions_subdir = os.path.join(
144 self.workbench.dir, 'convirsions')
145
146 self.orig_filename = proc_state.get_orig_filename()
147 self.name_builder = FilenameBuilder(self.orig_filename)
148
149 # Exif extraction
150 self.exif_tags = extract_exif(self.orig_filename)
151
152 os.mkdir(self.conversions_subdir)
153
154 def reprocess_action(self, args):
155 """
156 List the available actions for media in a given state
157 """
158 if args[0].state == 'processed':
159 print _('\n Available reprocessing actions for processed images:'
160 '\n \t --resize: thumb or medium'
161 '\n Options:'
162 '\n \t --size: max_width max_height (defaults to'
163 'config specs)')
164 return True
165
166 def _parser(self, args):
167 """
168 Parses the unknown args from the gmg parser
169 """
170 parser = argparse.ArgumentParser()
171 parser.add_argument(
172 '--resize',
173 choices=['thumb', 'medium'])
174 parser.add_argument(
175 '--size',
176 nargs=2,
177 type=int)
178 parser.add_argument(
179 '--initial_processing',
180 action='store_true')
181
182 return parser.parse_args(args[1])
183
184 def _check_eligible(self, entry_args, reprocess_args):
185 """
186 Check to see if we can actually process the given media as requested
187 """
188
189 if entry_args.state == 'processed':
190 if reprocess_args.initial_processing:
191 raise Exception(_('You can not run --initial_processing on'
192 ' media that has already been processed.'))
193
194 if entry_args.state == 'failed':
195 if reprocess_args.resize:
196 raise Exception(_('You can not run --resize on media that has'
197 ' not been processed.'))
198 if reprocess_args.size:
199 _log.warn('With --initial_processing, the --size flag will be'
200 ' ignored.')
201
202 if entry_args.state == 'processing':
203 raise Exception(_('We currently do not support reprocessing on'
204 ' media that is in the "processing" state.'))
205
206 def initial_processing(self):
207 # Is there any GPS data
208 gps_data = get_gps_data(self.exif_tags)
209
210 # Always create a small thumbnail
211 resize_tool(self.proc_state, True, 'thumb', self.orig_filename,
212 self.name_builder.fill('{basename}.thumbnail{ext}'),
213 self.conversions_subdir, self.exif_tags)
214
215 # Possibly create a medium
216 resize_tool(self.proc_state, False, 'medium', self.orig_filename,
217 self.name_builder.fill('{basename}.medium{ext}'),
218 self.conversions_subdir, self.exif_tags)
219
220 # Copy our queued local workbench to its final destination
221 self.proc_state.copy_original(self.name_builder.fill('{basename}{ext}'))
222
223 # Remove queued media file from storage and database
224 self.proc_state.delete_queue_file()
225
226 # Insert exif data into database
227 exif_all = clean_exif(self.exif_tags)
228
229 if len(exif_all):
230 self.entry.media_data_init(exif_all=exif_all)
231
232 if len(gps_data):
233 for key in list(gps_data.keys()):
234 gps_data['gps_' + key] = gps_data.pop(key)
235 self.entry.media_data_init(**gps_data)
236
237 def reprocess(self, reprocess_info):
238 """
239 This function actually does the reprocessing when called by
240 ProcessMedia in gmg/processing/task.py
241 """
242 new_size = None
243
244 # Did they specify a size? They must specify either both or none, so
245 # we only need to check if one is present
246 if reprocess_info.get('max_width'):
247 max_width = reprocess_info['max_width']
248 max_height = reprocess_info['max_height']
249
250 new_size = (max_width, max_height)
251
252 resize_tool(self.proc_state, False, reprocess_info['resize'],
253 self.name_builder.fill('{basename}.medium{ext}'),
254 self.conversions_subdir, self.exif_tags, new_size)
255
256 def media_reprocess(self, args):
257 """
258 This function handles the all of the reprocessing logic, before calling
259 gmg/submit/lib/run_process_media
260 """
261 reprocess_args = self._parser(args)
262 entry_args = args[0]
263
264 # Can we actually process the given media as requested?
265 self._check_eligible(entry_args, reprocess_args)
266
267 # Do we want to re-try initial processing?
268 if reprocess_args.initial_processing:
269 for id in entry_args.media_id:
270 entry = MediaEntry.query.filter_by(id=id).first()
271 run_process_media(entry)
272
273 # Are we wanting to resize the thumbnail or medium?
274 elif reprocess_args.resize:
275
276 # reprocess all given media entries
277 for id in entry_args.media_id:
278 entry = MediaEntry.query.filter_by(id=id).first()
279
280 # For now we can only reprocess with the original file
281 if not entry.media_files.get('original'):
282 raise Exception(_('The original file for this media entry'
283 ' does not exist.'))
284
285 reprocess_info = self._get_reprocess_info(reprocess_args)
286 run_process_media(entry, reprocess_info=reprocess_info)
287
288 # If we are here, they forgot to tell us how to reprocess
289 else:
290 _log.warn('You must set either --resize or --initial_processing'
291 ' flag to reprocess an image.')
292
293 def _get_reprocess_info(self, args):
294 """ Returns a dict with the info needed for reprocessing"""
295 reprocess_info = {'resize': args.resize}
296
297 if args.size:
298 reprocess_info['max_width'] = args.size[0]
299 reprocess_info['max_height'] = args.size[1]
300
301 return reprocess_info
302
303 if __name__ == '__main__':
304 import sys
305 import pprint
306
307 pp = pprint.PrettyPrinter()
308
309 result = extract_exif(sys.argv[1])
310 gps = get_gps_data(result)
311 clean = clean_exif(result)
312 useful = get_useful(clean)
313
314 print pp.pprint(
315 clean)