summaryrefslogtreecommitdiff
path: root/exifilm.py
diff options
context:
space:
mode:
Diffstat (limited to 'exifilm.py')
-rw-r--r--exifilm.py383
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
19import pygtk
20pygtk.require('2.0')
21import gtk, gobject
22import gtk.glade
23import gtk.keysyms
24from gtk import gdk
25
26import os, sys
27import pyexiv2
28import datetime
29import decimal, fractions
30import re
31
32HALF_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
37THIRD_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
43CAMERA_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
54ID = 'ID'
55FILM = 'Film'
56CARRIER = 'Carrier'
57PROCESS = 'Process'
58FRONT_MOVEMENTS = 'Front movements'
59REAR_MOVEMENTS = 'Rear movements'
60
61PRIVATE_KEYS = [
62 ID,
63 FILM,
64 CARRIER,
65 PROCESS,
66 FRONT_MOVEMENTS,
67 REAR_MOVEMENTS,
68 ]
69
70ISO_RE = re.compile(r'\d\d+')
71
72def 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
79def 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
86def 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
99def to_fstop(v):
100 if '/' in v:
101 n,d = v.split('/')
102 return str(float(n)/float(d))
103 return v
104
105def 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
113def 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
128class 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
369def main():
370 gdk.threads_init()
371 gdk.threads_enter()
372 ExiFilm()
373 gtk.main()
374 gdk.threads_leave()
375
376if __name__=='__main__':
377 if len(sys.argv)>1 and sys.argv[1][0]=='-':
378 print "Usage: %s [PATH]" % sys.argv[0]
379 print
380 print " PATH is a directory with JPEG files to edit."
381 print " PATH defaults to the current directory."
382 else:
383 main()