Spaces:
No application file
No application file
| # -*- coding: utf-8 -*- | |
| """ Adopted from https://github.com/foobar167/junkyard/blob/master/manual_image_annotation1/polygon/gui_canvas.py """ | |
| import os | |
| import sys | |
| import time | |
| import math | |
| import tkinter as tk | |
| from tkinter import ttk | |
| from PIL import Image, ImageTk | |
| def handle_exception(exit_code=0): | |
| """ Use: @land.logger.handle_exception(0) | |
| before every function which could cast an exception """ | |
| def wrapper(func): | |
| def inner(*args, **kwargs): | |
| try: | |
| return func(*args, **kwargs) | |
| except: | |
| if exit_code != 0: # if zero, don't exit from the program | |
| sys.exit(exit_code) # exit from the program | |
| return inner | |
| return wrapper | |
| class AutoScrollbar(ttk.Scrollbar): | |
| """ A scrollbar that hides itself if it's not needed. Works only for grid geometry manager """ | |
| def set(self, lo, hi): | |
| if float(lo) <= 0.0 and float(hi) >= 1.0: | |
| self.grid_remove() | |
| else: | |
| self.grid() | |
| ttk.Scrollbar.set(self, lo, hi) | |
| def pack(self, **kw): | |
| raise tk.TclError('Cannot use pack with the widget ' + self.__class__.__name__) | |
| def place(self, **kw): | |
| raise tk.TclError('Cannot use place with the widget ' + self.__class__.__name__) | |
| class CanvasImage: | |
| """ Display and zoom image """ | |
| def __init__(self, canvas_frame, canvas): | |
| """ Initialize the ImageFrame """ | |
| self.current_scale = 1.0 # scale for the canvas image zoom, public for outer classes | |
| self.__delta = 1.2 # zoom magnitude | |
| self.__previous_state = 0 # previous state of the keyboard | |
| # Create ImageFrame in placeholder widget | |
| self.__imframe = canvas_frame | |
| # Vertical and horizontal scrollbars for canvas | |
| self.hbar = AutoScrollbar(canvas_frame, orient='horizontal') | |
| self.vbar = AutoScrollbar(canvas_frame, orient='vertical') | |
| self.hbar.grid(row=1, column=0, sticky='we') | |
| self.vbar.grid(row=0, column=1, sticky='ns') | |
| # Add scroll bars to canvas | |
| self.canvas = canvas | |
| self.canvas.configure(xscrollcommand=self.hbar.set, yscrollcommand=self.vbar.set) | |
| self.hbar.configure(command=self.__scroll_x) # bind scrollbars to the canvas | |
| self.vbar.configure(command=self.__scroll_y) | |
| # Bind events to the Canvas | |
| self.canvas.bind('<Configure>', lambda event: self.__size_changed()) # canvas is resized | |
| self.canvas.bind('<Button-1>', self.__left_mouse_button) # remember canvas position | |
| self.canvas.bind('<ButtonPress-3>', self.__right_mouse_button_pressed) # remember canvas position | |
| self.canvas.bind('<ButtonPress-2>', self.__right_mouse_button_pressed) # remember canvas position (MacOS) | |
| self.canvas.bind('<ButtonRelease-3>', self.__right_mouse_button_released) # remember canvas position | |
| self.canvas.bind('<ButtonRelease-2>', self.__right_mouse_button_released) # remember canvas position (MacOS) | |
| self.canvas.bind('<B3-Motion>', self.__right_mouse_button_motion) # move canvas to the new position | |
| self.canvas.bind('<B2-Motion>', self.__right_mouse_button_motion) # move canvas to the new position | |
| self.canvas.bind('<MouseWheel>', self.__wheel) # zoom for Windows and MacOS, but not Linux | |
| self.canvas.bind('<Button-5>', self.__wheel) # zoom for Linux, wheel scroll down | |
| self.canvas.bind('<Button-4>', self.__wheel) # zoom for Linux, wheel scroll up | |
| # Handle keystrokes in idle mode, because program slows down on a weak computers, | |
| # when too many key stroke events in the same time | |
| self.canvas.bind('<Key>', lambda event: self.canvas.after_idle(self.__keystroke, event)) | |
| self.container = None | |
| self._click_callback = None | |
| def register_click_callback(self, click_callback): | |
| self._click_callback = click_callback | |
| def reload_image(self, image, reset_canvas=True): | |
| self.__original_image = image.copy() | |
| self.__current_image = image.copy() | |
| if reset_canvas: | |
| self.imwidth, self.imheight = self.__original_image.size | |
| self.__min_side = min(self.imwidth, self.imheight) # get the smaller image side | |
| scale = min(self.canvas.winfo_width() / self.imwidth, self.canvas.winfo_height() / self.imheight) | |
| if self.container: | |
| self.canvas.delete(self.container) | |
| self.container = self.canvas.create_rectangle((0, 0, scale * self.imwidth, scale * self.imheight), width=0) | |
| self.current_scale = scale | |
| self._reset_canvas_offset() | |
| self.__show_image() # show image on the canvas | |
| self.canvas.focus_set() # set focus on the canvas | |
| def grid(self, **kw): | |
| """ Put CanvasImage widget on the parent widget """ | |
| self.__imframe.grid(**kw) # place CanvasImage widget on the grid | |
| self.__imframe.grid(sticky='nswe') # make frame container sticky | |
| self.__imframe.rowconfigure(0, weight=1) # make canvas expandable | |
| self.__imframe.columnconfigure(0, weight=1) | |
| def __show_image(self): | |
| box_image = self.canvas.coords(self.container) # get image area | |
| box_canvas = (self.canvas.canvasx(0), # get visible area of the canvas | |
| self.canvas.canvasy(0), | |
| self.canvas.canvasx(self.canvas.winfo_width()), | |
| self.canvas.canvasy(self.canvas.winfo_height())) | |
| box_img_int = tuple(map(int, box_image)) # convert to integer or it will not work properly | |
| # Get scroll region box | |
| box_scroll = [min(box_img_int[0], box_canvas[0]), min(box_img_int[1], box_canvas[1]), | |
| max(box_img_int[2], box_canvas[2]), max(box_img_int[3], box_canvas[3])] | |
| # Horizontal part of the image is in the visible area | |
| if box_scroll[0] == box_canvas[0] and box_scroll[2] == box_canvas[2]: | |
| box_scroll[0] = box_img_int[0] | |
| box_scroll[2] = box_img_int[2] | |
| # Vertical part of the image is in the visible area | |
| if box_scroll[1] == box_canvas[1] and box_scroll[3] == box_canvas[3]: | |
| box_scroll[1] = box_img_int[1] | |
| box_scroll[3] = box_img_int[3] | |
| # Convert scroll region to tuple and to integer | |
| self.canvas.configure(scrollregion=tuple(map(int, box_scroll))) # set scroll region | |
| x1 = max(box_canvas[0] - box_image[0], 0) # get coordinates (x1,y1,x2,y2) of the image tile | |
| y1 = max(box_canvas[1] - box_image[1], 0) | |
| x2 = min(box_canvas[2], box_image[2]) - box_image[0] | |
| y2 = min(box_canvas[3], box_image[3]) - box_image[1] | |
| if int(x2 - x1) > 0 and int(y2 - y1) > 0: # show image if it in the visible area | |
| border_width = 2 | |
| sx1, sx2 = x1 / self.current_scale, x2 / self.current_scale | |
| sy1, sy2 = y1 / self.current_scale, y2 / self.current_scale | |
| crop_x, crop_y = max(0, math.floor(sx1 - border_width)), max(0, math.floor(sy1 - border_width)) | |
| crop_w, crop_h = math.ceil(sx2 - sx1 + 2 * border_width), math.ceil(sy2 - sy1 + 2 * border_width) | |
| crop_w = min(crop_w, self.__original_image.width - crop_x) | |
| crop_h = min(crop_h, self.__original_image.height - crop_y) | |
| __current_image = self.__original_image.crop((crop_x, crop_y, | |
| crop_x + crop_w, crop_y + crop_h)) | |
| crop_zw = int(round(crop_w * self.current_scale)) | |
| crop_zh = int(round(crop_h * self.current_scale)) | |
| zoom_sx, zoom_sy = crop_zw / crop_w, crop_zh / crop_h | |
| crop_zx, crop_zy = crop_x * zoom_sx, crop_y * zoom_sy | |
| self.real_scale = (zoom_sx, zoom_sy) | |
| interpolation = Image.NEAREST if self.current_scale > 2.0 else Image.ANTIALIAS | |
| __current_image = __current_image.resize((crop_zw, crop_zh), interpolation) | |
| zx1, zy1 = x1 - crop_zx, y1 - crop_zy | |
| zx2 = min(zx1 + self.canvas.winfo_width(), __current_image.width) | |
| zy2 = min(zy1 + self.canvas.winfo_height(), __current_image.height) | |
| self.__current_image = __current_image.crop((zx1, zy1, zx2, zy2)) | |
| imagetk = ImageTk.PhotoImage(self.__current_image) | |
| imageid = self.canvas.create_image(max(box_canvas[0], box_img_int[0]), | |
| max(box_canvas[1], box_img_int[1]), | |
| anchor='nw', image=imagetk) | |
| self.canvas.lower(imageid) # set image into background | |
| self.canvas.imagetk = imagetk # keep an extra reference to prevent garbage-collection | |
| def _get_click_coordinates(self, event): | |
| x = self.canvas.canvasx(event.x) # get coordinates of the event on the canvas | |
| y = self.canvas.canvasy(event.y) | |
| if self.outside(x, y): | |
| return None | |
| box_image = self.canvas.coords(self.container) | |
| x = max(x - box_image[0], 0) | |
| y = max(y - box_image[1], 0) | |
| x = int(x / self.real_scale[0]) | |
| y = int(y / self.real_scale[1]) | |
| return x, y | |
| # ================================================ Canvas Routines ================================================= | |
| def _reset_canvas_offset(self): | |
| self.canvas.configure(scrollregion=(0, 0, 5000, 5000)) | |
| self.canvas.scan_mark(0, 0) | |
| self.canvas.scan_dragto(int(self.canvas.canvasx(0)), int(self.canvas.canvasy(0)), gain=1) | |
| def _change_canvas_scale(self, relative_scale, x=0, y=0): | |
| new_scale = self.current_scale * relative_scale | |
| if new_scale > 20: | |
| return | |
| if new_scale * self.__original_image.width < self.canvas.winfo_width() and \ | |
| new_scale * self.__original_image.height < self.canvas.winfo_height(): | |
| return | |
| self.current_scale = new_scale | |
| self.canvas.scale('all', x, y, relative_scale, relative_scale) # rescale all objects | |
| # noinspection PyUnusedLocal | |
| def __scroll_x(self, *args, **kwargs): | |
| """ Scroll canvas horizontally and redraw the image """ | |
| self.canvas.xview(*args) # scroll horizontally | |
| self.__show_image() # redraw the image | |
| # noinspection PyUnusedLocal | |
| def __scroll_y(self, *args, **kwargs): | |
| """ Scroll canvas vertically and redraw the image """ | |
| self.canvas.yview(*args) # scroll vertically | |
| self.__show_image() # redraw the image | |
| def __size_changed(self): | |
| new_scale_w = self.canvas.winfo_width() / (self.current_scale * self.__original_image.width) | |
| new_scale_h = self.canvas.winfo_height() / (self.current_scale * self.__original_image.height) | |
| new_scale = min(new_scale_w, new_scale_h) | |
| if new_scale > 1.0: | |
| self._change_canvas_scale(new_scale) | |
| self.__show_image() | |
| # ================================================ Mouse callbacks ================================================= | |
| def __wheel(self, event): | |
| """ Zoom with mouse wheel """ | |
| x = self.canvas.canvasx(event.x) # get coordinates of the event on the canvas | |
| y = self.canvas.canvasy(event.y) | |
| if self.outside(x, y): return # zoom only inside image area | |
| scale = 1.0 | |
| # Respond to Linux (event.num) or Windows (event.delta) wheel event | |
| if event.num == 5 or event.delta == -120 or event.delta == 1: # scroll down, zoom out, smaller | |
| scale /= self.__delta | |
| if event.num == 4 or event.delta == 120 or event.delta == -1: # scroll up, zoom in, bigger | |
| scale *= self.__delta | |
| self._change_canvas_scale(scale, x, y) | |
| self.__show_image() | |
| def __left_mouse_button(self, event): | |
| if self._click_callback is None: | |
| return | |
| coords = self._get_click_coordinates(event) | |
| if coords is not None: | |
| self._click_callback(is_positive=True, x=coords[0], y=coords[1]) | |
| def __right_mouse_button_pressed(self, event): | |
| """ Remember previous coordinates for scrolling with the mouse """ | |
| self._last_rb_click_time = time.time() | |
| self._last_rb_click_event = event | |
| self.canvas.scan_mark(event.x, event.y) | |
| def __right_mouse_button_released(self, event): | |
| time_delta = time.time() - self._last_rb_click_time | |
| move_delta = math.sqrt((event.x - self._last_rb_click_event.x) ** 2 + | |
| (event.y - self._last_rb_click_event.y) ** 2) | |
| if time_delta > 0.5 or move_delta > 3: | |
| return | |
| if self._click_callback is None: | |
| return | |
| coords = self._get_click_coordinates(self._last_rb_click_event) | |
| if coords is not None: | |
| self._click_callback(is_positive=False, x=coords[0], y=coords[1]) | |
| def __right_mouse_button_motion(self, event): | |
| """ Drag (move) canvas to the new position """ | |
| move_delta = math.sqrt((event.x - self._last_rb_click_event.x) ** 2 + | |
| (event.y - self._last_rb_click_event.y) ** 2) | |
| if move_delta > 3: | |
| self.canvas.scan_dragto(event.x, event.y, gain=1) | |
| self.__show_image() # zoom tile and show it on the canvas | |
| def outside(self, x, y): | |
| """ Checks if the point (x,y) is outside the image area """ | |
| bbox = self.canvas.coords(self.container) # get image area | |
| if bbox[0] < x < bbox[2] and bbox[1] < y < bbox[3]: | |
| return False # point (x,y) is inside the image area | |
| else: | |
| return True # point (x,y) is outside the image area | |
| # ================================================= Keys Callback ================================================== | |
| def __keystroke(self, event): | |
| """ Scrolling with the keyboard. | |
| Independent from the language of the keyboard, CapsLock, <Ctrl>+<key>, etc. """ | |
| if event.state - self.__previous_state == 4: # means that the Control key is pressed | |
| pass # do nothing if Control key is pressed | |
| else: | |
| self.__previous_state = event.state # remember the last keystroke state | |
| # Up, Down, Left, Right keystrokes | |
| self.keycodes = {} # init key codes | |
| if os.name == 'nt': # Windows OS | |
| self.keycodes = { | |
| 'd': [68, 39, 102], | |
| 'a': [65, 37, 100], | |
| 'w': [87, 38, 104], | |
| 's': [83, 40, 98], | |
| } | |
| else: # Linux OS | |
| self.keycodes = { | |
| 'd': [40, 114, 85], | |
| 'a': [38, 113, 83], | |
| 'w': [25, 111, 80], | |
| 's': [39, 116, 88], | |
| } | |
| if event.keycode in self.keycodes['d']: # scroll right, keys 'd' or 'Right' | |
| self.__scroll_x('scroll', 1, 'unit', event=event) | |
| elif event.keycode in self.keycodes['a']: # scroll left, keys 'a' or 'Left' | |
| self.__scroll_x('scroll', -1, 'unit', event=event) | |
| elif event.keycode in self.keycodes['w']: # scroll up, keys 'w' or 'Up' | |
| self.__scroll_y('scroll', -1, 'unit', event=event) | |
| elif event.keycode in self.keycodes['s']: # scroll down, keys 's' or 'Down' | |
| self.__scroll_y('scroll', 1, 'unit', event=event) | |