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 # "perspect-run" is there only to visualize something is running,
210 # take it out to run convert silently in the background.
211 cmd = ["perspect-run", "convert", "-verbose", glob.sourcefile, "-auto-orient"]
213 cmd.extend(["-matte", "-virtual-pixel", "transparent", "-distort", "Perspective", coordinatepairs])
215 cmd.extend(["-crop", "%dx%d+%d+%d" % (rightmost-leftmost, bottommost-topmost, leftmost, topmost), "+repage"])
216 run_command_with_tempfile(cmd)
218 def run_command_with_tempfile(cmd):
219 _, suffix = os.path.splitext(glob.sourcefile)
220 _fd, outfile = tempfile.mkstemp(suffix=suffix)
221 cmd.extend([outfile])
222 run_command_background(cmd, callback=(cb_imagemagick, {'outfile': outfile, 'original_file': glob.sourcefile}))
224 def cb_imagemagick(err, user_params):
226 # don't display erro message on gtk gui because it's in a forked process.
227 sys.stderr.write("imagemagick error: %d\n" % err)
229 os.environ['PERSPECT_ORIGIN_FILE'] = user_params['original_file']
230 outfile = user_params['outfile']
231 subprocess.Popen([sys.executable, sys.argv[0], outfile], stdout=sys.stdout, stderr=sys.stderr)
233 def run_command_background(cmd, callback=None):
234 # start convert in detached background process
237 os.closerange(3, 65535)
241 err = subprocess.call(cmd, stdout=sys.stdout, stderr=sys.stderr)
242 if callback is not None:
243 callback[0](err, *callback[1:])
250 def do_distortion_and_crop(*_):
251 do_magick(distortion=True, crop=True)
253 def do_distortion(*_):
254 do_magick(distortion=True, crop=False)
257 do_magick(distortion=False, crop=True)
259 def get_linesegment_angle(A, B):
260 tan = float(A.x - B.x) / float(A.y - B.y)
261 ang = math.degrees(math.atan(tan))
262 return ang, ang + 90 if ang <= 0 else ang - 90
264 def do_rotation_horizontal(*_):
265 vangle, hangle = get_linesegment_angle(coordinates[0], coordinates[1])
266 cmd = get_rotation_params(hangle)
267 run_command_with_tempfile(cmd)
269 def do_rotation_vertical(*_):
270 vangle, hangle = get_linesegment_angle(coordinates[0], coordinates[1])
271 cmd = get_rotation_params(vangle)
272 run_command_with_tempfile(cmd)
274 def do_rotation_horizontal_and_autocrop(*_):
275 vangle, hangle = get_linesegment_angle(coordinates[0], coordinates[1])
276 cmd = get_rotation_params(hangle)
277 cmd.extend(["-fuzz", "20%", "-trim", "+repage"])
278 run_command_with_tempfile(cmd)
280 def do_rotation_vertical_and_autocrop(*_):
281 vangle, hangle = get_linesegment_angle(coordinates[0], coordinates[1])
282 cmd = get_rotation_params(vangle)
283 cmd.extend(["-fuzz", "20%", "-trim", "+repage"])
284 run_command_with_tempfile(cmd)
286 def get_rotation_params(angle):
287 cmd = ["perspect-run", "convert", glob.sourcefile, "-auto-orient", "-rotate", str(angle)]
292 def imagerotation_cb(transformation, user_data):
293 if transformation == imagerotation.FLIP:
294 user_data['pixbuf'] = user_data['pixbuf'].flip(horizontal=False)
295 elif transformation == imagerotation.FLOP:
296 user_data['pixbuf'] = user_data['pixbuf'].flip(horizontal=True)
297 elif transformation == imagerotation.CLOCKWISE:
298 user_data['pixbuf'] = user_data['pixbuf'].rotate_simple(gtk.gdk.buf_ROTATE_CLOCKWISE)
299 elif transformation == imagerotation.UPSIDEDOWN:
300 user_data['pixbuf'] = user_data['pixbuf'].rotate_simple(gtk.gdk.buf_ROTATE_UPSIDEDOWN)
301 elif transformation == imagerotation.COUNTERCLOCKWISE:
302 user_data['pixbuf'] = user_data['pixbuf'].rotate_simple(gtk.gdk.buf_ROTATE_COUNTERCLOCKWISE)
304 #warnx("unknown image transformation code %d" % transformation)
307 def load_image(imageobj, sourcefile):
308 loader = gtk.gdk.PixbufLoader()
309 imagedata = open(sourcefile, 'r').read()
310 loader.write(imagedata)
312 pixbuf = loader.get_pixbuf()
314 # transform the image on screen according to the file's EXIF metadata
315 rotation = {'pixbuf': pixbuf}
316 irot = imagemetadata.Image(imagedata = imagedata)
317 irot.rotate(imagerotation_cb, rotation)
319 image.set_from_pixbuf(rotation['pixbuf'])
322 run_command_background(["gpicview", glob.sourcefile])
325 proposed_path = os.environ.get('PERSPECT_ORIGIN_FILE', glob.sourcefile)
326 save_path = file_choose_dialog_save(proposed_path)
327 if save_path is not None:
328 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):
330 copyfile(glob.sourcefile, save_path)
331 except Exception as e:
335 def display_error(e):
337 if isinstance(e, OSError) or isinstance(e, IOError):
338 text = '%s (#%d)' % (e.strerror, e.errno)
339 if e.filename is not None:
340 text += '\n%s' % (e.filename)
341 elif isinstance(e, Exception):
343 elif type(e) == type([]):
347 dlg = gtk.MessageDialog(window, gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT, gtk.MESSAGE_ERROR, gtk.BUTTONS_OK, text)
348 dlg.set_title("Error")
352 def question(msg, stock_yes=None, stock_no=None, parent=None):
353 dlg = gtk.MessageDialog(parent, gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT, gtk.MESSAGE_QUESTION, gtk.BUTTONS_YES_NO)
355 dlg.set_title("Question")
356 if stock_no is not None:
357 dlg.get_widget_for_response(gtk.RESPONSE_NO).hide()
358 if hasattr(stock_no, '__iter__'):
359 btn_no = StockButton(label=stock_no[0], stock=stock_no[1])
361 btn_no = StockButton(stock=stock_no)
362 dlg.add_action_widget(btn_no, gtk.RESPONSE_NO)
364 if stock_yes is not None:
365 dlg.get_widget_for_response(gtk.RESPONSE_YES).hide()
366 if hasattr(stock_yes, '__iter__'):
367 btn_yes = StockButton(label=stock_yes[0], stock=stock_yes[1])
369 btn_yes = StockButton(stock=stock_yes)
370 dlg.add_action_widget(btn_yes, gtk.RESPONSE_YES)
374 return (resp == gtk.RESPONSE_YES)
376 def file_choose_dialog_save(filepath):
379 except NameError: LastFolder = None
381 action = gtk.FILE_CHOOSER_ACTION_SAVE
383 dlg = gtk.FileChooserDialog(parent=window, action=action, buttons=(gtk.STOCK_CANCEL, gtk.RESPONSE_REJECT, gtk.STOCK_SAVE, gtk.RESPONSE_ACCEPT))
384 if LastFolder is not None: dlg.set_current_folder(LastFolder)
385 dlg.set_current_name(os.path.basename(filepath))
387 last_resp_num = max(map(lambda a: int(getattr(gtk, a)), filter(lambda a: a.startswith('RESPONSE_'), dir(gtk))))
389 resp_num_cwd = last_resp_num + 1
390 btn_cwd = StockButton(label="Working Dir", stock=gtk.STOCK_JUMP_TO)
391 dlg.add_action_widget(btn_cwd, resp_num_cwd)
394 resp_num_fdir = last_resp_num + 2
395 btn_fdir = StockButton(label="Jump to File", stock=gtk.STOCK_JUMP_TO)
396 dlg.add_action_widget(btn_fdir, resp_num_fdir)
401 if resp == gtk.RESPONSE_ACCEPT:
402 selected = dlg.get_filename()
404 elif resp == resp_num_cwd:
405 dlg.set_current_folder(os.getcwd())
406 elif resp == resp_num_fdir:
407 dlg.set_current_folder(os.path.dirname(filepath))
410 LastFolder = dlg.get_current_folder()
414 class SimpleStore(object):
417 def toggle_quadrangle(*X):
418 glob.show_quadrangle = not glob.show_quadrangle
421 def toggle_circumrectangle(*X):
422 glob.show_circumrectangle = not glob.show_circumrectangle
425 def load_file(filepath):
426 load_image(image, filepath)
427 glob.sourcefile = filepath
428 window.set_title(window.get_title() + ": " + glob.sourcefile)
430 USER_LOG_FILE = '~/.perspect.coords.log'
432 def do_save_coordinates(*X):
433 curr_coords = get_quadrangle_points()
434 last_coords = read_last_saved_coordinates()
435 # append surrently pointed coordinates to the log file
436 # unless the exact same coords were saved most recently.
437 if any([cc.x != lc.x or cc.y != lc.y for cc, lc in zip(curr_coords, last_coords)]):
438 with open(os.path.expanduser(USER_LOG_FILE), 'a') as fh:
439 fh.write("%s %s %s %s\n" % curr_coords[0:4])
441 def read_last_saved_coordinates(*X):
442 last_coords = (Coordinate(-1, -1), Coordinate(-1, -1), Coordinate(-1, -1), Coordinate(-1, -1))
444 filecontext = open(os.path.expanduser(USER_LOG_FILE), 'r')
445 except (OSError, IOError) as e:
446 traceback.print_exc()
449 with filecontext as fh:
451 except IOError as e: pass
456 if pos == 0: start_anchor_re = '^'
457 else: start_anchor_re = r'(?<=\n)'
458 matches = re.findall(start_anchor_re + r'(\S+) (\S+) (\S+) (\S+)(?=\n|$)', buf)
461 last_coords = [Coordinate(x) for x in matches[-1]]
466 except IOError as e: fh.seek(0, 0)
469 def do_load_coordinates(*X):
470 # replace currently pointed coordinates to the last saved ones
471 last_coords = read_last_saved_coordinates()
472 last_coords = [c for c in last_coords if not (c.x == -1 or c.y == -1)]
475 coordinates.extend(last_coords)
480 text = """Billentyűparancsok
482 <b>F1</b> : Perspektíva javítás
483 <b>Shift + F1</b> : Perspektíva javítás és méretre vágás
484 <b>F2</b> : Méretre vágás
485 <b>F3</b> : Forgatás úgy hogy a meghúzott vonal vízszintes legyen
486 <b>F4</b> : Forgatás úgy hogy a meghúzott vonal függőleges legyen
487 <b>Shift + F3/F4</b> : Forgatás ugyanígy és automatikus méretre vágás
489 <b>F5</b> : Kijelölt koordináták mentése <tt>"""+USER_LOG_FILE+"""</tt> fájlba
490 <b>F6</b> : Utoljára mentett koordináták visszaállítása
491 <b>Q</b> : Kijelölt négyszöget mutat/elrejt
492 <b>R</b> : Körülírt téglalapot mutat/elrejt
494 <b>Ctrl + S</b> : Aktuális fájl mentése más néven
495 <b>Ctrl + O</b> : Aktuális fájl megnyitása külső programmal
496 <b>Escape</b> : Kilépés
498 <b>Bal egérgomb</b> : Pontok megjelölése (max 4) egy szakasz vagy négyszög meghúzásához
499 <b>Jobb egérgomb</b> : A legközelebbi pont törlése
500 <b>Középső egérgomb</b> : Meghúzott szakasz vagy sokszög újrarajzoltatása
501 <b>Alt + Bal egérgomb</b> : Ablak mozgatása (ha az ablakkezelő támogatja)
503 dlg = gtk.MessageDialog(window, gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT, gtk.MESSAGE_INFO, gtk.BUTTONS_OK)
505 dlg.set_title("Help")
511 window = gtk.Window()
512 window.set_title("Perspective correction")
513 window.connect('delete-event', lambda *x: gtk.main_quit())
514 add_key_binding(window, 'Escape', gtk.main_quit)
515 add_key_binding(window, 'question', show_help)
516 add_key_binding(window, 'r', toggle_circumrectangle)
517 add_key_binding(window, 'q', toggle_quadrangle)
518 add_key_binding(window, 'F1', do_distortion)
519 add_key_binding(window, '<Shift>F1', do_distortion_and_crop)
520 add_key_binding(window, 'F2', do_crop)
521 add_key_binding(window, 'F3', do_rotation_horizontal)
522 add_key_binding(window, '<Shift>F3', do_rotation_horizontal_and_autocrop)
523 add_key_binding(window, 'F4', do_rotation_vertical)
524 add_key_binding(window, '<Shift>F4', do_rotation_vertical_and_autocrop)
525 add_key_binding(window, 'F5', do_save_coordinates)
526 add_key_binding(window, 'F6', do_load_coordinates)
527 add_key_binding(window, '<Control>S', do_save_as)
528 add_key_binding(window, '<Control>O', do_open)
531 statusline = gtk.Label()
532 statusline.set_selectable(True)
533 statusline.set_alignment(0, 0)
537 glob.show_quadrangle = True
538 glob.show_circumrectangle = False
540 image.connect('button-release-event', on_clicked)
542 load_file(sys.argv[1])
549 box1.pack_start(image, True, True)
550 box1.pack_start(statusline, False, True)
553 window.window.set_cursor(gtk.gdk.Cursor(gtk.gdk.CROSSHAIR))