1

(Python 2.7). I have a Tkinter canvas with two images that are both the height and width of the canvas, so they cover the whole window. One image is on top of the other. I want to, using the mouse, be able to erase part of the top image wherever I want, thus exposing the bottom image. Is this possible?

I'm curious in how to implement the Home.erase method below which is bound to a Tkinter motion event.

# -*- coding: utf-8 -*-

import io
from PIL import Image, ImageTk
import Tkinter as tk

#Image 2 is on top of image 1.
IMAGE1_DIR = "C:/path_to_image/image1.png"
IMAGE2_DIR = "C:/path_to_image/image2.png"

def create_image(filename, width=0, height=0):
    """
    Returns a PIL.Image object from filename - sized
    according to width and height parameters.

    filename: str.
    width: int, desired image width.
    height: int, desired image height.

    1) If neither width nor height is given, image will be returned as is.
    2) If both width and height are given, image will resized accordingly.
    3) If only width or only height is given, image will be scaled so specified
    parameter is satisfied while keeping image's original aspect ratio the same. 
    """
    with open(filename, "rb") as f:
        fh = io.BytesIO(f.read())

    #Create a PIL image from the data
    img = Image.open(fh, mode="r")

    #Resize if necessary.
    if not width and not height:
        return img
    elif width and height:
        return img.resize((int(width), int(height)), Image.ANTIALIAS)
    else:  #Keep aspect ratio.
        w, h = img.size
        scale = width/float(w) if width else height/float(h)
        return img.resize((int(w*scale), int(h*scale)), Image.ANTIALIAS)


class Home(object):
    """
    master: tk.Tk window.
    screen: tuple, (width, height).
    """
    def __init__(self, master, screen):
        self.screen = w, h = screen
        self.master = master

        self.frame = tk.Frame(self.master)
        self.frame.pack()
        self.can = tk.Canvas(self.frame, width=w, height=h)
        self.can.pack()

        #Photos will be as large as the screen.
        p1 = ImageTk.PhotoImage(image=create_image(IMAGE1_DIR, w, h))
        p2 = ImageTk.PhotoImage(image=create_image(IMAGE2_DIR, w, h))

        ## Place photos in center of screen.
        ## Create label to retain a reference to image so it doesn't dissapear.

        self.photo1 = self.can.create_image((w//2, h//2), image=p1)        
        label1 = tk.Label(image=p1)
        label1.image = p1

        #On top.
        self.photo2 = self.can.create_image((w//2, h//2), image=p2)
        label2 = tk.Label(image=p2)
        label2.image = p2

        #Key bindings.
        self.master.bind("<Return>", self.reset)
        self.master.bind("<Motion>", self.erase)

    #### Key Bindings ####
    def reset(self, event):
        """ Enter/Return key. """
        self.frame.destroy()
        self.__init__(self.master, self.screen)

    def erase(self, event):
        """
        Mouse motion binding.
        Erase part of top image (self.photo2) at location (event.x, event.y),
        consequently exposing part of the bottom image (self.photo1).
        """
        pass


def main(screen=(500, 500)):
    root = tk.Tk()
    root.resizable(0, 0)
    Home(root, screen)

    #Place window in center of screen.
    root.eval('tk::PlaceWindow %s center'%root.winfo_pathname(root.winfo_id()))

    root.mainloop()


if __name__ == '__main__':
    main()
Joe
  • 116
  • 1
  • 16
  • You could implement this by loading both images into memory but only displaying the one on top. Afterwards the `erase()` method could selectively copy pixels to it from the bottom one simulating the effect. – martineau Aug 14 '16 at 22:07
  • How would I go about selectively copying pixles? – Joe Aug 14 '16 at 22:11
  • 1
    PIL `Image` objects have [`getpixel()`](http://pillow.readthedocs.io/en/3.3.x/reference/Image.html#PIL.Image.Image.getpixel) and [`putpixel()`](http://pillow.readthedocs.io/en/3.3.x/reference/Image.html#PIL.Image.Image.putpixel) methods. – martineau Aug 14 '16 at 23:21
  • Actually, I don't think doing what I suggested is going to be feasible with Tkinter, because it's going to require converting the entire updated PIL `Image` into a `ImageTk.PhotoImage` every time a pixel is changed. As far as I know, Tkinter doesn't provide any sort of image pixel access which makes it unsuitable for doing this sort of thing (or, at best, really inefficient at it). You might get away with "erasing" larger rectangular areas all at once, I suppose. – martineau Aug 14 '16 at 23:43
  • Well it worked! I would mark you as best answer if I could! – Joe Aug 15 '16 at 05:31
  • Glad to hear you got it working—despite my concerns about efficiency. Please post your working code as an answer (and accept it if no one comes up with something better). It would be interested to see exactly how you did it. – martineau Aug 15 '16 at 13:20
  • 1
    P.S. Speaking of efficiency, why do you load the image in a two step process using `io.BytesIO` in `create_image()` when you could just do it by just passing `Image.open()` the filename? – martineau Aug 15 '16 at 13:24
  • Good question...I don't know why I do that. Thanks! – Joe Aug 15 '16 at 14:47

1 Answers1

1

Thanks to @martineau for the suggestions! Here is the working code.

from PIL import Image, ImageTk
import Tkinter as tk

#Image 2 is on top of image 1.
IMAGE1_DIR = "C:/path/image1.PNG"
IMAGE2_DIR = "C:/path/image2.PNG"

#Brush size in pixels.
BRUSH = 5
#Actual size is 2*BRUSH

def create_image(filename, width=0, height=0):
    """
    Returns a PIL.Image object from filename - sized
    according to width and height parameters.

    filename: str.
    width: int, desired image width.
    height: int, desired image height.

    1) If neither width nor height is given, image will be returned as is.
    2) If both width and height are given, image will resized accordingly.
    3) If only width or only height is given, image will be scaled so specified
    parameter is satisfied while keeping image's original aspect ratio the same. 
    """
    #Create a PIL image from the file.
    img = Image.open(filename, mode="r")

    #Resize if necessary.
    if not width and not height:
        return img
    elif width and height:
        return img.resize((int(width), int(height)), Image.ANTIALIAS)
    else:  #Keep aspect ratio.
        w, h = img.size
        scale = width/float(w) if width else height/float(h)
        return img.resize((int(w*scale), int(h*scale)), Image.ANTIALIAS)


class Home(object):
    """
    master: tk.Tk window.
    screen: tuple, (width, height).
    """
    def __init__(self, master, screen):
        self.screen = w, h = screen
        self.master = master

        self.frame = tk.Frame(self.master)
        self.frame.pack()
        self.can = tk.Canvas(self.frame, width=w, height=h)
        self.can.pack()

        self.image1 = create_image(IMAGE1_DIR, w, h)
        self.image2 = create_image(IMAGE2_DIR, w, h)        

        #Center of screen.
        self.center = w//2, h//2
        #Start with no photo on the screen.
        self.photo = False

        #Draw photo on screen.
        self.draw()

        #Key bindings.
        self.master.bind("<Return>", self.reset)
        self.master.bind("<Motion>", self.erase)

    def draw(self):
        """
        If there is a photo on the canvas, destroy it.
        Draw self.image2 on the canvas.
        """            
        if self.photo:
            self.can.delete(self.photo)
            self.label.destroy()

        p = ImageTk.PhotoImage(image=self.image2)
        self.photo = self.can.create_image(self.center, image=p)
        self.label = tk.Label(image=p)
        self.label.image = p

    #### Key Bindings ####
    def reset(self, event):
        """ Enter/Return key. """
        self.frame.destroy()
        self.__init__(self.master, self.screen)

    def erase(self, event):
        """
        Mouse motion binding.
        Erase part of top image (self.photo2) at location (event.x, event.y),
        consequently exposing part of the bottom image (self.photo1).
        """        
        for x in xrange(event.x-BRUSH, event.x+BRUSH+1):
            for y in xrange(event.y-BRUSH, event.y+BRUSH+1):
                try:
                    p = self.image1.getpixel((x, y))
                    self.image2.putpixel((x, y), p)
                except IndexError:
                    pass

        self.draw()



def main(screen=(500, 500)):
    root = tk.Tk()
    root.resizable(0, 0)
    Home(root, screen)

    #Place window in center of screen.
    root.eval('tk::PlaceWindow %s center'%root.winfo_pathname(root.winfo_id()))

    root.mainloop()


if __name__ == '__main__':
    main()
Joe
  • 116
  • 1
  • 16
  • 1
    Optimization suggestion: Since your `erase()` method is effectively copying all the pixels in a rectangular region defined by the `BRUSH` size from the bottom to the top image, it would be faster to do them all at once by making one call to the [`Image.paste()`](http://pillow.readthedocs.io/en/3.3.x/reference/Image.html#PIL.Image.Image.paste) method than it is by doing each pixel individually via those nested `for` loops. – martineau Aug 15 '16 at 16:41
  • 1
    Check out the PIL demo called painter.py for a somewhat different approach: http://svn.effbot.org/public/pil/Scripts/ – Oblivion Aug 15 '16 at 21:54
  • 1
    FWIW, even if you aren't using the canvas subclass tiling-scheme from [`painter.py`](http://svn.effbot.org/public/pil/Scripts/painter.py), the way its `paint()` method uses [`Image.crop()`](http://pillow.readthedocs.io/en/3.3.x/reference/Image.html#PIL.Image.Image.crop) and [`Image.paste()`](http://pillow.readthedocs.io/en/3.3.x/reference/Image.html#PIL.Image.Image.paste) together would be a good way to implement what I suggested in my earlier comment. – martineau Aug 16 '16 at 13:54