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() | ||
