diff options
Diffstat (limited to 'exifilm.py')
-rw-r--r-- | exifilm.py | 383 |
1 files changed, 383 insertions, 0 deletions
diff --git a/exifilm.py b/exifilm.py new file mode 100644 index 0000000..17db62d --- /dev/null +++ b/exifilm.py | |||
@@ -0,0 +1,383 @@ | |||
1 | #!/usr/bin/python | ||
2 | |||
3 | # ExiFilm -- Add film exposure metadata to EXIF tags of digital images | ||
4 | # Copyright (C) 2009 James E. Blair <corvus@gnu.org> | ||
5 | # | ||
6 | # This program is free software: you can redistribute it and/or modify | ||
7 | # it under the terms of the GNU General Public License as published by | ||
8 | # the Free Software Foundation, either version 3 of the License, or | ||
9 | # (at your option) any later version. | ||
10 | # | ||
11 | # This program is distributed in the hope that it will be useful, | ||
12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
14 | # GNU General Public License for more details. | ||
15 | # | ||
16 | # You should have received a copy of the GNU General Public License | ||
17 | # along with this program. If not, see <http://www.gnu.org/licenses/>. | ||
18 | |||
19 | import pygtk | ||
20 | pygtk.require('2.0') | ||
21 | import gtk, gobject | ||
22 | import gtk.glade | ||
23 | import gtk.keysyms | ||
24 | from gtk import gdk | ||
25 | |||
26 | import os, sys | ||
27 | import pyexiv2 | ||
28 | import datetime | ||
29 | import decimal, fractions | ||
30 | import re | ||
31 | |||
32 | HALF_STOP_SCALE = ['1', '1.2', '1.4', '1.7', '2', '2.4', '2.8', '3.3', '4', '4.8', | ||
33 | '5.6', '6.7', '8', '9.5', '11', '13', '16', '19', '22', '27', | ||
34 | '32', '38', '45', '54', '64', '76', '91'] | ||
35 | |||
36 | |||
37 | THIRD_STOP_SCALE = ['1', '1.1', '1.2', '1.4', '1.6', '1.8', '2', '2.2', '2.5', | ||
38 | '2.8', '3.2', '3.5', '4', '4.5', '5.0', '5.6', '6.3', '7.1', | ||
39 | '8', '9', '10', '11', '13', '14', '16', '18', '20', '22', | ||
40 | '25', '29', '32', '36', '40', '45', '51', '57', '64', '72', | ||
41 | '81', '91'] | ||
42 | |||
43 | CAMERA_MOVEMENTS = [ | ||
44 | 'rising', | ||
45 | 'falling', | ||
46 | 'forward tilt', | ||
47 | 'backward tilt', | ||
48 | 'left shift', | ||
49 | 'right shift', | ||
50 | 'left swing', | ||
51 | 'right swing', | ||
52 | ] | ||
53 | |||
54 | ID = 'ID' | ||
55 | FILM = 'Film' | ||
56 | CARRIER = 'Carrier' | ||
57 | PROCESS = 'Process' | ||
58 | FRONT_MOVEMENTS = 'Front movements' | ||
59 | REAR_MOVEMENTS = 'Rear movements' | ||
60 | |||
61 | PRIVATE_KEYS = [ | ||
62 | ID, | ||
63 | FILM, | ||
64 | CARRIER, | ||
65 | PROCESS, | ||
66 | FRONT_MOVEMENTS, | ||
67 | REAR_MOVEMENTS, | ||
68 | ] | ||
69 | |||
70 | ISO_RE = re.compile(r'\d\d+') | ||
71 | |||
72 | def to_rational(v): | ||
73 | if '/' in v: | ||
74 | n,d = v.split('/') | ||
75 | else: | ||
76 | n,d = (v,1) | ||
77 | return pyexiv2.Rational(int(n), int(d)) | ||
78 | |||
79 | def from_rational(v): | ||
80 | if not v: return v | ||
81 | n,d = str(v).split('/') | ||
82 | if d == '1': | ||
83 | return str(n) | ||
84 | return str(v) | ||
85 | |||
86 | def from_fstop(v): | ||
87 | if '/' in v: | ||
88 | whole, frac = v.split(' ') | ||
89 | n,d = frac.split('/') | ||
90 | if d == '2': scale = HALF_STOP_SCALE | ||
91 | if d == '3': scale = THIRD_STOP_SCALE | ||
92 | i = scale.index(whole) | ||
93 | i += int(n) | ||
94 | f = fractions.Fraction.from_decimal(decimal.Decimal(str(scale[i]))) | ||
95 | return str(f) | ||
96 | f = fractions.Fraction.from_decimal(decimal.Decimal(v)) | ||
97 | return str(f) | ||
98 | |||
99 | def to_fstop(v): | ||
100 | if '/' in v: | ||
101 | n,d = v.split('/') | ||
102 | return str(float(n)/float(d)) | ||
103 | return v | ||
104 | |||
105 | def encode_comments(d, comments): | ||
106 | ret = '' | ||
107 | for k in PRIVATE_KEYS: | ||
108 | if k in d.keys(): | ||
109 | ret += '%s: %s\n' % (k, d[k]) | ||
110 | ret += '\n'+comments | ||
111 | return ret | ||
112 | |||
113 | def decode_comments(comments): | ||
114 | d = {} | ||
115 | ret = '' | ||
116 | lines = comments.split('\n') | ||
117 | while lines: | ||
118 | line = lines.pop(0).strip() | ||
119 | if not line: break | ||
120 | if ':' in line: | ||
121 | k,v = line.split(':', 1) | ||
122 | d[k.strip()] = v.strip() | ||
123 | else: | ||
124 | ret += line+'\n' | ||
125 | break | ||
126 | return d, ret+'\n'.join(lines) | ||
127 | |||
128 | class ExiFilm(object): | ||
129 | |||
130 | def __init__(self): | ||
131 | self.image = None | ||
132 | self.xml = gtk.glade.XML('exifilm.glade', 'main_window') | ||
133 | |||
134 | self.file_combo = self.xml.get_widget('file') | ||
135 | |||
136 | self.id_entry = self.xml.get_widget('id') | ||
137 | self.film_entry = self.xml.get_widget('film') | ||
138 | self.carrier_entry = self.xml.get_widget('carrier') | ||
139 | self.process_entry = self.xml.get_widget('process') | ||
140 | self.lens_entry = self.xml.get_widget('lens') | ||
141 | self.aperture_entry = self.xml.get_widget('aperture') | ||
142 | self.shutter_entry = self.xml.get_widget('shutter') | ||
143 | |||
144 | self.front_button = self.xml.get_widget('front') | ||
145 | self.rear_button = self.xml.get_widget('rear') | ||
146 | |||
147 | self.description_entry = self.xml.get_widget('description') | ||
148 | self.date_button = self.xml.get_widget('date') | ||
149 | |||
150 | self.comments_text = self.xml.get_widget('comments') | ||
151 | self.comments_buffer = gtk.TextBuffer() | ||
152 | self.comments_text.set_buffer(self.comments_buffer) | ||
153 | |||
154 | # Keep the last selected date to provide a sane default | ||
155 | self.lastdate = None | ||
156 | |||
157 | dic = { | ||
158 | 'on_main_window_destroy': self.window_closed, | ||
159 | 'on_main_window_key_press_event': self.key_press, | ||
160 | 'on_date_clicked': self.date_clicked, | ||
161 | 'on_front_clicked': self.front_clicked, | ||
162 | 'on_rear_clicked': self.rear_clicked, | ||
163 | 'on_file_changed': self.file_changed, | ||
164 | } | ||
165 | |||
166 | self.xml.signal_autoconnect (dic) | ||
167 | |||
168 | self.file_store = gtk.ListStore(gobject.TYPE_STRING) | ||
169 | cell = gtk.CellRendererText() | ||
170 | self.file_combo.pack_start(cell, True) | ||
171 | self.file_combo.add_attribute(cell, 'text', 0) | ||
172 | self.file_combo.set_model(self.file_store) | ||
173 | |||
174 | self.filename = None | ||
175 | first = None | ||
176 | self.directory = '.' | ||
177 | if len(sys.argv)>1: | ||
178 | self.directory = sys.argv[1] | ||
179 | files = os.listdir(self.directory) | ||
180 | files.sort() | ||
181 | for fn in files: | ||
182 | if not (fn.lower().endswith('.jpeg') or | ||
183 | fn.lower().endswith('.jpg')): | ||
184 | continue | ||
185 | if not first: first = fn | ||
186 | self.file_store.append((fn,)) | ||
187 | if first: | ||
188 | self.file_combo.set_active(0) | ||
189 | |||
190 | def file_changed(self, widget, data=None): | ||
191 | if self.filename: | ||
192 | self.save() | ||
193 | i = self.file_combo.get_active() | ||
194 | fn = self.file_store[i][0] | ||
195 | self.load(fn) | ||
196 | self.id_entry.grab_focus() | ||
197 | |||
198 | def key_press(self, widget, data=None): | ||
199 | if data.keyval == gtk.keysyms.Page_Up: | ||
200 | i = self.file_combo.get_active() | ||
201 | if i>0: | ||
202 | self.file_combo.set_active(i-1) | ||
203 | return True | ||
204 | |||
205 | if data.keyval == gtk.keysyms.Page_Down: | ||
206 | i = self.file_combo.get_active() | ||
207 | if i<len(self.file_store)-1: | ||
208 | self.file_combo.set_active(i+1) | ||
209 | return True | ||
210 | |||
211 | def load(self, fn): | ||
212 | fn = os.path.join(self.directory, fn) | ||
213 | self.filename = fn | ||
214 | |||
215 | image = pyexiv2.Image(fn) | ||
216 | image.readMetadata() | ||
217 | keys = image.exifKeys() | ||
218 | self.image = image | ||
219 | |||
220 | def get(key, default=''): | ||
221 | if key in keys: | ||
222 | return image[key] | ||
223 | return default | ||
224 | |||
225 | self.date = get('Exif.Photo.DateTimeOriginal', None) | ||
226 | if self.date: | ||
227 | self.date_button.set_label(str(self.date.date())) | ||
228 | else: | ||
229 | self.date_button.set_label('Set') | ||
230 | |||
231 | self.shutter_entry.set_text(from_rational(get('Exif.Photo.ExposureTime'))) | ||
232 | self.aperture_entry.set_text(to_fstop(from_rational(get('Exif.Photo.FNumber')))) | ||
233 | self.lens_entry.set_text(from_rational(get('Exif.Photo.FocalLength'))) | ||
234 | |||
235 | self.description_entry.set_text(get('Exif.Image.ImageDescription')) | ||
236 | extras, comments = decode_comments(get('Exif.Photo.UserComment')) | ||
237 | self.comments_buffer.set_text(comments) | ||
238 | |||
239 | self.id_entry.set_text(extras.get(ID, '')) | ||
240 | self.film_entry.set_text(extras.get(FILM, '')) | ||
241 | self.carrier_entry.set_text(extras.get(CARRIER, '')) | ||
242 | self.process_entry.set_text(extras.get(PROCESS, '')) | ||
243 | |||
244 | self.front_movements = extras.get(FRONT_MOVEMENTS, '') | ||
245 | if self.front_movements: | ||
246 | self.front_button.set_label(self.front_movements) | ||
247 | else: | ||
248 | self.front_button.set_label('None') | ||
249 | |||
250 | self.rear_movements = extras.get(REAR_MOVEMENTS, '') | ||
251 | if self.rear_movements: | ||
252 | self.rear_button.set_label(self.rear_movements) | ||
253 | else: | ||
254 | self.rear_button.set_label('None') | ||
255 | |||
256 | def save(self): | ||
257 | if self.date: | ||
258 | self.image['Exif.Photo.DateTimeOriginal'] = self.date | ||
259 | v = self.shutter_entry.get_text() | ||
260 | if v: self.image['Exif.Photo.ExposureTime'] = to_rational(v) | ||
261 | v = self.aperture_entry.get_text() | ||
262 | if v: self.image['Exif.Photo.FNumber'] = to_rational(from_fstop(v)) | ||
263 | v = self.lens_entry.get_text() | ||
264 | if v: self.image['Exif.Photo.FocalLength'] = to_rational(v) | ||
265 | v = self.description_entry.get_text() | ||
266 | if v: self.image['Exif.Image.ImageDescription'] = v | ||
267 | |||
268 | extras = {} | ||
269 | |||
270 | v = self.id_entry.get_text() | ||
271 | if v: extras[ID] = v | ||
272 | v = self.film_entry.get_text() | ||
273 | if v: | ||
274 | extras[FILM] = v | ||
275 | m = ISO_RE.search(v) | ||
276 | if m: | ||
277 | iso = int(m.group(0)) | ||
278 | self.image['Exif.Photo.ISOSpeedRatings'] = iso | ||
279 | v = self.carrier_entry.get_text() | ||
280 | if v: extras[CARRIER] = v | ||
281 | v = self.process_entry.get_text() | ||
282 | if v: extras[PROCESS] = v | ||
283 | if self.front_movements: | ||
284 | extras[FRONT_MOVEMENTS] = self.front_movements | ||
285 | if self.rear_movements: | ||
286 | extras[REAR_MOVEMENTS] = self.rear_movements | ||
287 | |||
288 | bounds = self.comments_buffer.get_bounds() | ||
289 | v = self.comments_buffer.get_text(bounds[0], bounds[1]) | ||
290 | if v or extras: | ||
291 | self.image['Exif.Photo.UserComment'] = encode_comments(extras, v) | ||
292 | |||
293 | self.image.writeMetadata() | ||
294 | |||
295 | def window_closed(self, widget, data=None): | ||
296 | if self.image: | ||
297 | self.save() | ||
298 | gtk.main_quit() | ||
299 | |||
300 | def date_clicked(self, widget, data=None): | ||
301 | xml = gtk.glade.XML('exifilm.glade', 'calendar_dialog') | ||
302 | calendar_dialog = xml.get_widget('calendar_dialog') | ||
303 | calendar = xml.get_widget('calendar') | ||
304 | if self.date: | ||
305 | date = self.date | ||
306 | else: | ||
307 | if self.lastdate: | ||
308 | date = self.lastdate | ||
309 | else: | ||
310 | date = datetime.datetime.now() | ||
311 | calendar.select_month(date.month-1, date.year) | ||
312 | calendar.select_day(date.day) | ||
313 | r = calendar_dialog.run() | ||
314 | if r: | ||
315 | date = calendar.get_date() | ||
316 | self.date = datetime.datetime(date[0], date[1]+1, date[2]) | ||
317 | self.lastdate = self.date | ||
318 | self.date_button.set_label(str(self.date.date())) | ||
319 | calendar_dialog.destroy() | ||
320 | |||
321 | def front_clicked(self, widget, data=None): | ||
322 | moves = self.front_movements.split('/') | ||
323 | moves = self.get_movements(moves) | ||
324 | self.front_movements = '/'.join(moves) | ||
325 | if self.front_movements: | ||
326 | self.front_button.set_label(self.front_movements) | ||
327 | else: | ||
328 | self.front_button.set_label('None') | ||
329 | |||
330 | def rear_clicked(self, widget, data=None): | ||
331 | moves = self.rear_movements.split('/') | ||
332 | moves = self.get_movements(moves) | ||
333 | self.rear_movements = '/'.join(moves) | ||
334 | if self.rear_movements: | ||
335 | self.rear_button.set_label(self.rear_movements) | ||
336 | else: | ||
337 | self.rear_button.set_label('None') | ||
338 | |||
339 | def get_movements(self, orig_movements): | ||
340 | xml = gtk.glade.XML('exifilm.glade', 'movement_dialog') | ||
341 | dialog = xml.get_widget('movement_dialog') | ||
342 | tree_view = xml.get_widget('movements') | ||
343 | |||
344 | column = gtk.TreeViewColumn("Movements") | ||
345 | renderer=gtk.CellRendererText() | ||
346 | column.pack_start(renderer,0) | ||
347 | column.add_attribute(renderer, 'text', 0) | ||
348 | tree_view.append_column(column) | ||
349 | |||
350 | tree_store = gtk.TreeStore(str) | ||
351 | tree_view.set_model(tree_store) | ||
352 | tree_view.get_selection().set_mode(gtk.SELECTION_MULTIPLE) | ||
353 | for i,m in enumerate(CAMERA_MOVEMENTS): | ||
354 | tree_store.append(None, (m,)) | ||
355 | if m in orig_movements: | ||
356 | tree_view.get_selection().select_path((i,)) | ||
357 | |||
358 | r = dialog.run() | ||
359 | if r: | ||
360 | store, rows = tree_view.get_selection().get_selected_rows() | ||
361 | ret = [] | ||
362 | for row in rows: | ||
363 | ret.append(tree_store.get_value(tree_store.get_iter(row), 0)) | ||
364 | else: | ||
365 | ret = orig_movements | ||
366 | dialog.destroy() | ||
367 | return ret | ||
368 | |||
369 | def main(): | ||
370 | gdk.threads_init() | ||
371 | gdk.threads_enter() | ||
372 | ExiFilm() | ||
373 | gtk.main() | ||
374 | gdk.threads_leave() | ||
375 | |||
376 | if __name__=='__main__': | ||
377 | if len(sys.argv)>1 and sys.argv[1][0]=='-': | ||
378 | print "Usage: %s [PATH]" % sys.argv[0] | ||
379 | |||
380 | print " PATH is a directory with JPEG files to edit." | ||
381 | print " PATH defaults to the current directory." | ||
382 | else: | ||
383 | main() | ||