1 #!/usr/bin/env python2.7
2 # -*- coding: utf-8 -*-
11 from shutil import copyfile
14 try: import better_exchook
15 except ImportError: pass
17 def add_key_binding(widget, keyname, callback):
18 accelgroup = gtk.AccelGroup()
19 key, modifier = gtk.accelerator_parse(keyname)
20 accelgroup.connect_group(key, modifier, gtk.ACCEL_VISIBLE, callback)
21 widget.add_accel_group(accelgroup)
23 class EventImage(gtk.EventBox):
25 super(self.__class__, self).__init__()
26 self.image = gtk.Image()
27 self.image.set_alignment(0, 0)
30 return self.image.clear()
31 def set_from_pixbuf(self, *args):
32 return self.image.set_from_pixbuf(*args)
33 def set_from_file(self, *args):
34 return self.image.set_from_file(*args)
35 def set_from_file_at_size(self, path, w, h):
36 pixbuf = gtk.gdk.pixbuf_new_from_file_at_size(path, w, h)
37 self.image.set_from_pixbuf(pixbuf)
38 def set_size_request(self, *args):
39 return self.image.set_size_request(*args)
42 pb = self.image.get_pixbuf()
43 return pb.get_width(), pb.get_height()
52 return self.image.get_pixbuf()
55 self.image.set_from_pixbuf(pb)
57 self.pixbuf = self.pixbuf
59 class StockButton(gtk.Button):
60 def __init__(self, label=None, stock=None, use_underline=True, icon_size=None):
61 if stock is not None and stock in gtk.stock_list_ids():
64 stock_tmp = gtk.STOCK_ABOUT
65 super(self.__class__, self).__init__(stock=stock_tmp, use_underline=use_underline)
67 self.set_markup(label)
70 elif stock not in gtk.stock_list_ids():
72 if icon_size is not None:
73 self.set_icon(stock, icon_size)
74 def __get_children(self):
75 align = self.get_children()[0]
76 hbox = align.get_children()[0]
77 return hbox.get_children()
78 def set_label(self, label):
79 x, lbl = self.__get_children()
81 def set_markup(self, label):
82 x, lbl = self.__get_children()
84 def set_icon(self, icon, size=gtk.ICON_SIZE_BUTTON):
85 img, x = self.__get_children()
88 img.props.visible = False
90 img.set_from_icon_name(icon, size)
91 img.props.visible = True
93 img.set_from_pixbuf(icon)
94 img.props.visible = True
96 class Coordinate(object):
97 def __init__(self, x, y=None):
108 return '%d,%d' % (self.x, self.y)
110 return '<%d,%d>' % (self.x, self.y)
112 def on_clicked(widget, event):
113 x = int(min(max(0, event.x), image.width))
114 y = int(min(max(0, event.y), image.height))
116 if event.button == 1:
117 if all([c.x!=x and c.y!=y for c in coordinates]):
118 if len(coordinates) >= 4:
119 remove_closest_point(x, y)
120 coordinates.append(Coordinate(x, y))
121 elif event.button == 3:
122 remove_closest_point(x, y)
126 def remove_closest_point(x, y):
127 if len(coordinates) > 0:
128 distances = map(lambda c: {'d': (abs(c.x - x)**2 + abs(c.y - y)**2)**0.5, 'p': c}, coordinates)
129 closest = sorted(distances, key=lambda x: x['d'])[0]['p']
130 coordinates.remove(closest)
132 def update_statusline():
133 topleft, bottomleft, topright, bottomright = get_quadrangle_points()
134 #text = '%d:%d; %d:%d; %d:%d; %d:%d' % (topleft.x, topleft.y, topright.x, topright.y, bottomright.x, bottomright.y, bottomleft.x, bottomleft.y)
135 text = '<span color="red">⸬</span> = '
136 text += ' '.join(map(lambda c: "(%d,%d)"%(c.x, c.y), filter(lambda c: c.x >= 0, [topleft, topright, bottomright, bottomleft])))
137 if len(coordinates) > 1:
138 leftmost, topmost, rightmost, bottommost = get_cirumrectangle()
139 text += ' <span color="green">▭</span> = %dx%d+%d+%d' % (rightmost-leftmost, bottommost-topmost, leftmost, topmost)
141 vangle, hangle = get_linesegment_angle(coordinates[0], coordinates[1])
142 text += ' ∡ = %0.2f° ⟂ %0.2f°' % (hangle, vangle)
144 statusline.set_markup(text)
146 def refresh_display():
148 glib.idle_add(draw_cages, priority=glib.PRIORITY_DEFAULT_IDLE)
151 if glob.show_quadrangle:
152 draw_quadrangle_cage()
153 if glob.show_circumrectangle:
154 draw_circumrectangle_cage()
156 def draw_quadrangle_cage():
157 topleft, bottomleft, topright, bottomright = get_quadrangle_points()
158 real_points = filter(lambda c: c.x >= 0, [topleft, topright, bottomright, bottomleft])
159 if len(real_points) > 0:
160 drawable = image.window
161 gc = gtk.gdk.GC(drawable)
162 gc.set_rgb_fg_color(gtk.gdk.color_parse('red'))
163 drawable.draw_lines(gc, [(c.x, c.y) for c in make_polygon(real_points)])
165 def draw_circumrectangle_cage():
166 if len(coordinates) > 1:
167 leftmost, topmost, rightmost, bottommost = get_cirumrectangle()
168 drawable = image.window
169 gc = gtk.gdk.GC(drawable)
170 gc.set_rgb_fg_color(gtk.gdk.color_parse('green'))
171 drawable.draw_lines(gc, [(leftmost, topmost), (rightmost, topmost), (rightmost, bottommost), (leftmost, bottommost), (leftmost, topmost)])
173 def make_polygon(points):
174 for i in range(len(points)):
178 def get_quadrangle_points():
179 points_left_to_right = sorted(coordinates, cmp=lambda a, b: cmp(a.x, b.x))
180 while len(points_left_to_right) < 4: points_left_to_right.append(Coordinate(-1, -1))
181 src_left_points = points_left_to_right[0:2]
182 src_right_points = points_left_to_right[-2:]
183 src_topleft = sorted(src_left_points, cmp=lambda a, b: cmp(a.y, b.y))[0]
184 src_bottomleft = sorted(src_left_points, cmp=lambda a, b: cmp(a.y, b.y))[1]
185 src_topright = sorted(src_right_points, cmp=lambda a, b: cmp(a.y, b.y))[0]
186 src_bottomright = sorted(src_right_points, cmp=lambda a, b: cmp(a.y, b.y))[1]
187 return src_topleft, src_bottomleft, src_topright, src_bottomright
189 def get_cirumrectangle():
190 leftmost = sorted(coordinates, cmp=lambda a, b: cmp(a.x, b.x))[0].x
191 topmost = sorted(coordinates, cmp=lambda a, b: cmp(a.y, b.y))[0].y
192 rightmost = sorted(coordinates, cmp=lambda a, b: cmp(a.x, b.x))[-1].x
193 bottommost = sorted(coordinates, cmp=lambda a, b: cmp(a.y, b.y))[-1].y
194 return leftmost, topmost, rightmost, bottommost
196 def do_magick(distortion=True, crop=True):
197 src_topleft, src_bottomleft, src_topright, src_bottomright = get_quadrangle_points()
198 leftmost, topmost, rightmost, bottommost = get_cirumrectangle()
199 trg_topleft = Coordinate(leftmost, topmost)
200 trg_bottomleft = Coordinate(leftmost, bottommost)
201 trg_topright = Coordinate(rightmost, topmost)
202 trg_bottomright = Coordinate(rightmost, bottommost)
203 coordinatepairs = ' '.join(map(str, [
204 src_topleft, trg_topleft,
205 src_topright, trg_topright,
206 src_bottomright, trg_bottomright,
207 src_bottomleft, trg_bottomleft,
209 cmd = ["convert", "-verbose", glob.sourcefile, "-auto-orient"]
211 cmd.extend(["-matte", "-virtual-pixel", "transparent", "-distort", "Perspective", coordinatepairs])
213 cmd.extend(["-crop", "%dx%d+%d+%d" % (rightmost-leftmost, bottommost-topmost, leftmost, topmost), "+repage"])
214 run_command_with_tempfile(cmd)
216 def run_command_with_tempfile(cmd):
217 _, suffix = os.path.splitext(glob.sourcefile)
218 _fd, outfile = tempfile.mkstemp(suffix=suffix)
219 cmd.extend([outfile])
220 run_command_background(cmd, callback=(cb_imagemagick, {'outfile': outfile, 'original_file': glob.sourcefile}))
222 def cb_imagemagick(err, user_params):
224 # don't display erro message on gtk gui because it's in a forked process.
225 sys.stderr.write("imagemagick error: %d\n" % err)
227 os.environ['PERSPECT_ORIGIN_FILE'] = user_params['original_file']
228 outfile = user_params['outfile']
229 subprocess.Popen([sys.executable, sys.argv[0], outfile], stdout=sys.stdout, stderr=sys.stderr)
231 def run_command_background(cmd, callback=None):
232 # start convert in detached background process
235 os.closerange(3, 65535)
239 err = subprocess.call(cmd, stdout=sys.stdout, stderr=sys.stderr)
240 if callback is not None:
241 callback[0](err, *callback[1:])
248 def do_distortion_and_crop(*_):
249 do_magick(distortion=True, crop=True)
251 def do_distortion(*_):
252 do_magick(distortion=True, crop=False)
255 do_magick(distortion=False, crop=True)
257 def get_linesegment_angle(A, B):
258 tan = float(A.x - B.x) / float(A.y - B.y)
259 ang = math.degrees(math.atan(tan))
260 return ang, ang + 90 if ang <= 0 else ang - 90
262 def do_rotation_horizontal(*_):
263 vangle, hangle = get_linesegment_angle(coordinates[0], coordinates[1])
264 cmd = get_rotation_params(hangle)
265 run_command_with_tempfile(cmd)
267 def do_rotation_vertical(*_):
268 vangle, hangle = get_linesegment_angle(coordinates[0], coordinates[1])
269 cmd = get_rotation_params(vangle)
270 run_command_with_tempfile(cmd)
272 def do_rotation_horizontal_and_autocrop(*_):
273 vangle, hangle = get_linesegment_angle(coordinates[0], coordinates[1])
274 cmd = get_rotation_params(hangle)
275 cmd.extend(["-fuzz", "20%", "-trim", "+repage"])
276 run_command_with_tempfile(cmd)
278 def do_rotation_vertical_and_autocrop(*_):
279 vangle, hangle = get_linesegment_angle(coordinates[0], coordinates[1])
280 cmd = get_rotation_params(vangle)
281 cmd.extend(["-fuzz", "20%", "-trim", "+repage"])
282 run_command_with_tempfile(cmd)
284 def get_rotation_params(angle):
285 cmd = ["convert", glob.sourcefile, "-auto-orient", "-rotate", str(angle)]
290 def imagerotation_cb(transformation, user_data):
291 if transformation == imagemetadata.FLIP:
292 user_data['pixbuf'] = user_data['pixbuf'].flip(horizontal=False)
293 elif transformation == imagemetadata.FLOP:
294 user_data['pixbuf'] = user_data['pixbuf'].flip(horizontal=True)
295 elif transformation == imagemetadata.CLOCKWISE:
296 user_data['pixbuf'] = user_data['pixbuf'].rotate_simple(gtk.gdk.PIXBUF_ROTATE_CLOCKWISE)
297 elif transformation == imagemetadata.UPSIDEDOWN:
298 user_data['pixbuf'] = user_data['pixbuf'].rotate_simple(gtk.gdk.PIXBUF_ROTATE_UPSIDEDOWN)
299 elif transformation == imagemetadata.COUNTERCLOCKWISE:
300 user_data['pixbuf'] = user_data['pixbuf'].rotate_simple(gtk.gdk.PIXBUF_ROTATE_COUNTERCLOCKWISE)
302 #warnx("unknown image transformation code %d" % transformation)
305 def load_image(imageobj, sourcefile):
306 loader = gtk.gdk.PixbufLoader()
307 imagedata = open(sourcefile, 'r').read()
308 loader.write(imagedata)
310 pixbuf = loader.get_pixbuf()
312 # transform the image on screen according to the file's EXIF metadata
313 rotation = {'pixbuf': pixbuf}
314 irot = imagemetadata.Image(imagedata = imagedata)
315 irot.rotate(imagerotation_cb, rotation)
317 image.set_from_pixbuf(rotation['pixbuf'])
320 run_command_background(["gpicview", glob.sourcefile])
323 proposed_path = os.environ.get('PERSPECT_ORIGIN_FILE', glob.sourcefile)
324 save_path = file_choose_dialog_save(proposed_path)
325 if save_path is not None:
326 if not os.path.exists(save_path) or question("Overwrite?\n<tt>%s</tt>"%glib.markup_escape_text(save_path), gtk.STOCK_SAVE, gtk.STOCK_CANCEL, window):
328 copyfile(glob.sourcefile, save_path)
329 except Exception as e:
333 def display_error(e):
335 if isinstance(e, OSError) or isinstance(e, IOError):
336 text = '%s (#%d)' % (e.strerror, e.errno)
337 if e.filename is not None:
338 text += '\n%s' % (e.filename)
339 elif isinstance(e, Exception):
341 elif type(e) == type([]):
345 dlg = gtk.MessageDialog(window, gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT, gtk.MESSAGE_ERROR, gtk.BUTTONS_OK, text)
346 dlg.set_title("Error")
350 def question(msg, stock_yes=None, stock_no=None, parent=None):
351 dlg = gtk.MessageDialog(parent, gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT, gtk.MESSAGE_QUESTION, gtk.BUTTONS_YES_NO)
353 dlg.set_title("Question")
354 if stock_no is not None:
355 dlg.get_widget_for_response(gtk.RESPONSE_NO).hide()
356 if hasattr(stock_no, '__iter__'):
357 btn_no = StockButton(label=stock_no[0], stock=stock_no[1])
359 btn_no = StockButton(stock=stock_no)
360 dlg.add_action_widget(btn_no, gtk.RESPONSE_NO)
362 if stock_yes is not None:
363 dlg.get_widget_for_response(gtk.RESPONSE_YES).hide()
364 if hasattr(stock_yes, '__iter__'):
365 btn_yes = StockButton(label=stock_yes[0], stock=stock_yes[1])
367 btn_yes = StockButton(stock=stock_yes)
368 dlg.add_action_widget(btn_yes, gtk.RESPONSE_YES)
372 return (resp == gtk.RESPONSE_YES)
374 def file_choose_dialog_save(filepath):
377 except NameError: LastFolder = None
379 action = gtk.FILE_CHOOSER_ACTION_SAVE
381 dlg = gtk.FileChooserDialog(parent=window, action=action, buttons=(gtk.STOCK_CANCEL, gtk.RESPONSE_REJECT, gtk.STOCK_SAVE, gtk.RESPONSE_ACCEPT))
382 if LastFolder is not None: dlg.set_current_folder(LastFolder)
383 dlg.set_current_name(os.path.basename(filepath))
385 last_resp_num = max(map(lambda a: int(getattr(gtk, a)), filter(lambda a: a.startswith('RESPONSE_'), dir(gtk))))
387 resp_num_cwd = last_resp_num + 1
388 btn_cwd = StockButton(label="Working Dir", stock=gtk.STOCK_JUMP_TO)
389 dlg.add_action_widget(btn_cwd, resp_num_cwd)
392 resp_num_fdir = last_resp_num + 2
393 btn_fdir = StockButton(label="Jump to File", stock=gtk.STOCK_JUMP_TO)
394 dlg.add_action_widget(btn_fdir, resp_num_fdir)
399 if resp == gtk.RESPONSE_ACCEPT:
400 selected = dlg.get_filename()
402 elif resp == resp_num_cwd:
403 dlg.set_current_folder(os.getcwd())
404 elif resp == resp_num_fdir:
405 dlg.set_current_folder(os.path.dirname(filepath))
408 LastFolder = dlg.get_current_folder()
412 class SimpleStore(object):
415 def toggle_quadrangle(*X):
416 glob.show_quadrangle = not glob.show_quadrangle
419 def toggle_circumrectangle(*X):
420 glob.show_circumrectangle = not glob.show_circumrectangle
423 def load_file(filepath):
424 load_image(image, filepath)
425 glob.sourcefile = filepath
426 window.set_title(window.get_title() + ": " + glob.sourcefile)
428 USER_LOG_FILE = '~/.perspect.coords.log'
430 def do_save_coordinates(*X):
431 curr_coords = get_quadrangle_points()
432 last_coords = read_last_saved_coordinates()
433 # append surrently pointed coordinates to the log file
434 # unless the exact same coords were saved most recently.
435 if any([cc.x != lc.x or cc.y != lc.y for cc, lc in zip(curr_coords, last_coords)]):
436 with open(os.path.expanduser(USER_LOG_FILE), 'a') as fh:
437 fh.write("%s %s %s %s\n" % curr_coords[0:4])
439 def read_last_saved_coordinates(*X):
440 last_coords = (Coordinate(-1, -1), Coordinate(-1, -1), Coordinate(-1, -1), Coordinate(-1, -1))
442 filecontext = open(os.path.expanduser(USER_LOG_FILE), 'r')
443 except (OSError, IOError) as e:
444 traceback.print_exc()
447 with filecontext as fh:
449 except IOError as e: pass
454 if pos == 0: start_anchor_re = '^'
455 else: start_anchor_re = r'(?<=\n)'
456 matches = re.findall(start_anchor_re + r'(\S+) (\S+) (\S+) (\S+)(?=\n|$)', buf)
459 last_coords = [Coordinate(x) for x in matches[-1]]
464 except IOError as e: fh.seek(0, 0)
467 def do_load_coordinates(*X):
468 # replace currently pointed coordinates to the last saved ones
469 last_coords = read_last_saved_coordinates()
470 last_coords = [c for c in last_coords if not (c.x == -1 or c.y == -1)]
473 coordinates.extend(last_coords)
478 text = """Billentyűparancsok
480 <b>F1</b> : Perspektíva javítás
481 <b>Shift + F1</b> : Perspektíva javítás és méretre vágás
482 <b>F2</b> : Méretre vágás
483 <b>F3</b> : Forgatás úgy hogy a meghúzott vonal vízszintes legyen
484 <b>F4</b> : Forgatás úgy hogy a meghúzott vonal függőleges legyen
485 <b>Shift + F3/F4</b> : Forgatás ugyanígy és automatikus méretre vágás
487 <b>F5</b> : Kijelölt koordináták mentése <tt>"""+USER_LOG_FILE+"""</tt> fájlba
488 <b>F6</b> : Utoljára mentett koordináták visszaállítása
489 <b>Q</b> : Kijelölt négyszöget mutat/elrejt
490 <b>R</b> : Körülírt téglalapot mutat/elrejt
492 <b>Ctrl + S</b> : Aktuális fájl mentése más néven
493 <b>Ctrl + O</b> : Aktuális fájl megnyitása külső programmal
494 <b>Escape</b> : Kilépés
496 <b>Bal egérgomb</b> : Pontok megjelölése (max 4) egy szakasz vagy négyszög meghúzásához
497 <b>Jobb egérgomb</b> : A legközelebbi pont törlése
498 <b>Középső egérgomb</b> : Meghúzott szakasz vagy sokszög újrarajzoltatása
499 <b>Alt + Bal egérgomb</b> : Ablak mozgatása (ha az ablakkezelő támogatja)
501 dlg = gtk.MessageDialog(window, gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT, gtk.MESSAGE_INFO, gtk.BUTTONS_OK)
503 dlg.set_title("Help")
509 window = gtk.Window()
510 window.set_title("Perspective correction")
511 window.connect('delete-event', lambda *x: gtk.main_quit())
512 add_key_binding(window, 'Escape', gtk.main_quit)
513 add_key_binding(window, 'question', show_help)
514 add_key_binding(window, 'r', toggle_circumrectangle)
515 add_key_binding(window, 'q', toggle_quadrangle)
516 add_key_binding(window, 'F1', do_distortion)
517 add_key_binding(window, '<Shift>F1', do_distortion_and_crop)
518 add_key_binding(window, 'F2', do_crop)
519 add_key_binding(window, 'F3', do_rotation_horizontal)
520 add_key_binding(window, '<Shift>F3', do_rotation_horizontal_and_autocrop)
521 add_key_binding(window, 'F4', do_rotation_vertical)
522 add_key_binding(window, '<Shift>F4', do_rotation_vertical_and_autocrop)
523 add_key_binding(window, 'F5', do_save_coordinates)
524 add_key_binding(window, 'F6', do_load_coordinates)
525 add_key_binding(window, '<Control>S', do_save_as)
526 add_key_binding(window, '<Control>O', do_open)
529 statusline = gtk.Label()
530 statusline.set_selectable(True)
531 statusline.set_alignment(0, 0)
535 glob.show_quadrangle = True
536 glob.show_circumrectangle = False
538 image.connect('button-release-event', on_clicked)
540 load_file(sys.argv[1])
547 box1.pack_start(image, True, True)
548 box1.pack_start(statusline, False, True)
551 window.window.set_cursor(gtk.gdk.Cursor(gtk.gdk.CROSSHAIR))