# -*- 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) @handle_exception(1) def pack(self, **kw): raise tk.TclError('Cannot use pack with the widget ' + self.__class__.__name__) @handle_exception(1) 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('', lambda event: self.__size_changed()) # canvas is resized self.canvas.bind('', self.__left_mouse_button) # remember canvas position self.canvas.bind('', self.__right_mouse_button_pressed) # remember canvas position self.canvas.bind('', self.__right_mouse_button_pressed) # remember canvas position (MacOS) self.canvas.bind('', self.__right_mouse_button_released) # remember canvas position self.canvas.bind('', self.__right_mouse_button_released) # remember canvas position (MacOS) self.canvas.bind('', self.__right_mouse_button_motion) # move canvas to the new position self.canvas.bind('', self.__right_mouse_button_motion) # move canvas to the new position self.canvas.bind('', self.__wheel) # zoom for Windows and MacOS, but not Linux self.canvas.bind('', self.__wheel) # zoom for Linux, wheel scroll down self.canvas.bind('', 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('', 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, +, 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)