Adding Zooming in and out with a Tkinter Canvas Widget in Python
Question: How would I add zooming in and out to the following script, i'd like to bind
it to the mousewheel. If you're testing this script on linux don't forget to
change the MouseWheel event to Button-4 and Button-5.

    
    
    from Tkinter import * 
    import Image, ImageTk
    
    class GUI:
        def __init__(self,root):
            frame = Frame(root, bd=2, relief=SUNKEN)
    
            frame.grid_rowconfigure(0, weight=1)
            frame.grid_columnconfigure(0, weight=1)
            xscrollbar = Scrollbar(frame, orient=HORIZONTAL)
            xscrollbar.grid(row=1, column=0, sticky=E+W)
            yscrollbar = Scrollbar(frame)
            yscrollbar.grid(row=0, column=1, sticky=N+S)
            self.canvas = Canvas(frame, bd=0, xscrollcommand=xscrollbar.set, yscrollcommand=yscrollbar.set, xscrollincrement = 10, yscrollincrement = 10)
            self.canvas.grid(row=0, column=0, sticky=N+S+E+W)
    
            File = "PATH TO JPG PICTURE HERE"
    
            self.img = ImageTk.PhotoImage(Image.open(File))
            self.canvas.create_image(0,0,image=self.img, anchor="nw")
            self.canvas.config(scrollregion=self.canvas.bbox(ALL))
            xscrollbar.config(command=self.canvas.xview)
            yscrollbar.config(command=self.canvas.yview)
    
            frame.pack()
    
            self.canvas.bind("<Button 3>",self.grab)
            self.canvas.bind("<B3-Motion>",self.drag)
            root.bind("<MouseWheel>",self.zoom)
    
    
        def grab(self,event):
            self._y = event.y
            self._x = event.x
    
        def drag(self,event):
            if (self._y-event.y < 0): self.canvas.yview("scroll",-1,"units")
            elif (self._y-event.y > 0): self.canvas.yview("scroll",1,"units")
            if (self._x-event.x < 0): self.canvas.xview("scroll",-1,"units")
            elif (self._x-event.x > 0): self.canvas.xview("scroll",1,"units")
            self._x = event.x
            self._y = event.y
    
        def zoom(self,event):
            if event.delta>0: print "ZOOM IN!"
            elif event.delta<0: print "ZOOM OUT!"
    
    
    root = Tk()   
    GUI(root)
    root.mainloop()
    


Answer: To my knowledge the built-in Tkinter Canvas class scale will not auto-scale
images. If you are unable to use a custom widget, you can scale the raw image
and replace it on the canvas when the scale function is invoked.

The code snippet below can be merged into your original class. It does the
following:

  1. Caches the result of `Image.open()`.
  2. Adds a `redraw()` function to calculate the scaled image and adds that to the canvas, and also removes the previously-drawn image if any.
  3. Uses the mouse coordinates as part of the image placement. I just pass `x and y` to the `create_image` function to show how the image placement shifts around as the mouse moves. You can replace this with your own center/offset calculation.
  4. This uses the Linux mousewheel buttons 4 and 5 (you'll need to generalize it to work on Windows, etc).

(**Updated**) Code:

    
    
    class GUI:
        def __init__(self, root):
    
            # ... omitted rest of initialization code
    
            self.canvas.config(scrollregion=self.canvas.bbox(ALL))
            self.scale = 1.0
            self.orig_img = Image.open(File)
            self.img = None
            self.img_id = None
            # draw the initial image at 1x scale
            self.redraw()
    
            # ... rest of init, bind buttons, pack frame
    
        def zoom(self,event):
            if event.num == 4:
                self.scale *= 2
            elif event.num == 5:
                self.scale *= 0.5
            self.redraw(event.x, event.y)
    
        def redraw(self, x=0, y=0):
            if self.img_id:
                self.canvas.delete(self.img_id)
            iw, ih = self.orig_img.size
            size = int(iw * self.scale), int(ih * self.scale)
            self.img = ImageTk.PhotoImage(self.orig_img.resize(size))
            self.img_id = self.canvas.create_image(x, y, image=self.img)
    
            # tell the canvas to scale up/down the vector objects as well
            self.canvas.scale(ALL, x, y, self.scale, self.scale)
    

**Update** I did a bit of testing for varying scales and found that quite a
bit of memory is being used by resize / create_image. I ran the test using a
540x375 JPEG on a Mac Pro with 32GB RAM. Here is the memory used for different
scale factors:

    
    
     1x  (500,     375)      14 M
     2x  (1000,    750)      19 M
     4x  (2000,   1500)      42 M
     8x  (4000,   3000)     181 M
    16x  (8000,   6000)     640 M
    32x  (16000, 12000)    1606 M
    64x  (32000, 24000)  ...  
    reached around ~7400 M and ran out of memory, EXC_BAD_ACCESS in _memcpy
    

Given the above, a more efficient solution might be to determine the size of
the viewport where the image will be displayed, calculate a cropping rectangle
around the center of the mouse coordinates, crop the image using the rect,
then scale just the cropped portion. This should use constant memory for
storing the temporary image. Otherwise you may need to use a 3rd party Tkinter
control which performs this cropping / windowed scaling for you.

**Update 2** Working but oversimplified cropping logic, just to get you
started:

    
    
        def redraw(self, x=0, y=0):
            if self.img_id: self.canvas.delete(self.img_id)
            iw, ih = self.orig_img.size
            # calculate crop rect
            cw, ch = iw / self.scale, ih / self.scale
            if cw > iw or ch > ih:
                cw = iw
                ch = ih
            # crop it
            _x = int(iw/2 - cw/2)
            _y = int(ih/2 - ch/2)
            tmp = self.orig_img.crop((_x, _y, _x + int(cw), _y + int(ch)))
            size = int(cw * self.scale), int(ch * self.scale)
            # draw
            self.img = ImageTk.PhotoImage(tmp.resize(size))
            self.img_id = self.canvas.create_image(x, y, image=self.img)
            gc.collect()
    

