9

I want to be able to drag rectangular selection with a mouse cursor over the image displayed in my program and to read the dimensions of the selection, so that I could use them to crop that image later. How do I do it in Python 3?

UPD:

Suppose I am doing it this way:

import tkinter as tk
from PIL import ImageTk, Image

#This creates the main window of an application
window = tk.Tk()
window.title("Join")
window.geometry("900x900")
window.configure(background='grey')

path = "Book.jpg"

#Creates a Tkinter-compatible photo image, which can be used everywhere Tkinter expects an image object.
img = ImageTk.PhotoImage(Image.open(path))

#The Label widget is a standard Tkinter widget used to display a text or image on the screen.
panel = tk.Label(window, image = img)

#The Pack geometry manager packs widgets in rows or columns.
panel.pack(side = "bottom", fill = "both", expand = "yes")

#Start the GUI
window.mainloop()
martineau
  • 119,623
  • 25
  • 170
  • 301
InfiniteLoop
  • 387
  • 1
  • 3
  • 18

2 Answers2

14

Here's another, unfortunately much more involved way to do it (because it does several of the things you mentioned also wanting to do in comments to my first answer). It shades the area outside of the select, and does so using tkinter's vector-graphic (not PIL's image-processing) capabilities, which I think makes it the lighter-weight, and maybe faster, too, approach since it doesn't involve processing relatively-large amounts of image data and transferring it.

Originally I tried to draw the shaded outside area as a single continuous polygon, but that didn't work because tkinter doesn't support such concave polygonal shapes, so four border-less rectangles are drawn instead—plus an empty one with a just a border to outline the selected region (pictures below).

I borrowed a few interesting ideas used in a ActiveState Code » Recipe titled Pʏᴛʜᴏɴ Tᴋɪɴᴛᴇʀ Cᴀɴᴠᴀs Rᴇᴄᴛᴀɴɢʟᴇ Sᴇʟᴇᴄᴛɪᴏɴ Bᴏx by Sunjay Varma.

The code is object-oriented, which hopefully will make it easier to understand (and extend). Note you can get the current selection rectangle as two points by calling the MousePositionTracker class instance's cur_selection() method, so that could be used to get the information needed to do the actual image cropping (which likely will involve using PIL).

import tkinter as tk
from PIL import Image, ImageTk


class MousePositionTracker(tk.Frame):
    """ Tkinter Canvas mouse position widget. """

    def __init__(self, canvas):
        self.canvas = canvas
        self.canv_width = self.canvas.cget('width')
        self.canv_height = self.canvas.cget('height')
        self.reset()

        # Create canvas cross-hair lines.
        xhair_opts = dict(dash=(3, 2), fill='white', state=tk.HIDDEN)
        self.lines = (self.canvas.create_line(0, 0, 0, self.canv_height, **xhair_opts),
                      self.canvas.create_line(0, 0, self.canv_width,  0, **xhair_opts))

    def cur_selection(self):
        return (self.start, self.end)

    def begin(self, event):
        self.hide()
        self.start = (event.x, event.y)  # Remember position (no drawing).

    def update(self, event):
        self.end = (event.x, event.y)
        self._update(event)
        self._command(self.start, (event.x, event.y))  # User callback.

    def _update(self, event):
        # Update cross-hair lines.
        self.canvas.coords(self.lines[0], event.x, 0, event.x, self.canv_height)
        self.canvas.coords(self.lines[1], 0, event.y, self.canv_width, event.y)
        self.show()

    def reset(self):
        self.start = self.end = None

    def hide(self):
        self.canvas.itemconfigure(self.lines[0], state=tk.HIDDEN)
        self.canvas.itemconfigure(self.lines[1], state=tk.HIDDEN)

    def show(self):
        self.canvas.itemconfigure(self.lines[0], state=tk.NORMAL)
        self.canvas.itemconfigure(self.lines[1], state=tk.NORMAL)

    def autodraw(self, command=lambda *args: None):
        """Setup automatic drawing; supports command option"""
        self.reset()
        self._command = command
        self.canvas.bind("<Button-1>", self.begin)
        self.canvas.bind("<B1-Motion>", self.update)
        self.canvas.bind("<ButtonRelease-1>", self.quit)

    def quit(self, event):
        self.hide()  # Hide cross-hairs.
        self.reset()


class SelectionObject:
    """ Widget to display a rectangular area on given canvas defined by two points
        representing its diagonal.
    """
    def __init__(self, canvas, select_opts):
        # Create attributes needed to display selection.
        self.canvas = canvas
        self.select_opts1 = select_opts
        self.width = self.canvas.cget('width')
        self.height = self.canvas.cget('height')

        # Options for areas outside rectanglar selection.
        select_opts1 = self.select_opts1.copy()  # Avoid modifying passed argument.
        select_opts1.update(state=tk.HIDDEN)  # Hide initially.
        # Separate options for area inside rectanglar selection.
        select_opts2 = dict(dash=(2, 2), fill='', outline='white', state=tk.HIDDEN)

        # Initial extrema of inner and outer rectangles.
        imin_x, imin_y,  imax_x, imax_y = 0, 0,  1, 1
        omin_x, omin_y,  omax_x, omax_y = 0, 0,  self.width, self.height

        self.rects = (
            # Area *outside* selection (inner) rectangle.
            self.canvas.create_rectangle(omin_x, omin_y,  omax_x, imin_y, **select_opts1),
            self.canvas.create_rectangle(omin_x, imin_y,  imin_x, imax_y, **select_opts1),
            self.canvas.create_rectangle(imax_x, imin_y,  omax_x, imax_y, **select_opts1),
            self.canvas.create_rectangle(omin_x, imax_y,  omax_x, omax_y, **select_opts1),
            # Inner rectangle.
            self.canvas.create_rectangle(imin_x, imin_y,  imax_x, imax_y, **select_opts2)
        )

    def update(self, start, end):
        # Current extrema of inner and outer rectangles.
        imin_x, imin_y,  imax_x, imax_y = self._get_coords(start, end)
        omin_x, omin_y,  omax_x, omax_y = 0, 0,  self.width, self.height

        # Update coords of all rectangles based on these extrema.
        self.canvas.coords(self.rects[0], omin_x, omin_y,  omax_x, imin_y),
        self.canvas.coords(self.rects[1], omin_x, imin_y,  imin_x, imax_y),
        self.canvas.coords(self.rects[2], imax_x, imin_y,  omax_x, imax_y),
        self.canvas.coords(self.rects[3], omin_x, imax_y,  omax_x, omax_y),
        self.canvas.coords(self.rects[4], imin_x, imin_y,  imax_x, imax_y),

        for rect in self.rects:  # Make sure all are now visible.
            self.canvas.itemconfigure(rect, state=tk.NORMAL)

    def _get_coords(self, start, end):
        """ Determine coords of a polygon defined by the start and
            end points one of the diagonals of a rectangular area.
        """
        return (min((start[0], end[0])), min((start[1], end[1])),
                max((start[0], end[0])), max((start[1], end[1])))

    def hide(self):
        for rect in self.rects:
            self.canvas.itemconfigure(rect, state=tk.HIDDEN)


class Application(tk.Frame):

    # Default selection object options.
    SELECT_OPTS = dict(dash=(2, 2), stipple='gray25', fill='red',
                          outline='')

    def __init__(self, parent, *args, **kwargs):
        super().__init__(parent, *args, **kwargs)

        path = "Books.jpg"
        img = ImageTk.PhotoImage(Image.open(path))
        self.canvas = tk.Canvas(root, width=img.width(), height=img.height(),
                                borderwidth=0, highlightthickness=0)
        self.canvas.pack(expand=True)

        self.canvas.create_image(0, 0, image=img, anchor=tk.NW)
        self.canvas.img = img  # Keep reference.

        # Create selection object to show current selection boundaries.
        self.selection_obj = SelectionObject(self.canvas, self.SELECT_OPTS)

        # Callback function to update it given two points of its diagonal.
        def on_drag(start, end, **kwarg):  # Must accept these arguments.
            self.selection_obj.update(start, end)

        # Create mouse position tracker that uses the function.
        self.posn_tracker = MousePositionTracker(self.canvas)
        self.posn_tracker.autodraw(command=on_drag)  # Enable callbacks.


if __name__ == '__main__':

    WIDTH, HEIGHT = 900, 900
    BACKGROUND = 'grey'
    TITLE = 'Image Cropper'

    root = tk.Tk()
    root.title(TITLE)
    root.geometry('%sx%s' % (WIDTH, HEIGHT))
    root.configure(background=BACKGROUND)

    app = Application(root, background=BACKGROUND)
    app.pack(side=tk.TOP, fill=tk.BOTH, expand=tk.TRUE)
    app.mainloop()

Here's some images showing it in action:

screenshot of start of selection process

screenshot of end of selection process

martineau
  • 119,623
  • 25
  • 170
  • 301
  • Amazing code really!! 1) How can I store the area I have selected from the image? 2) How can I store more than one time areas from the image? – just_learning Feb 09 '22 at 11:16
  • 1
    Thanks. As mentioned in the answer, you can call the `cur_selection()` method to get two corners that represent the selection area. To store multiple selection areas will depend on how your application is making use of the `MousePositionTracker` and `SelectionObject` classes. The `autodraw()` method of the latter, accepts a `command` callback argument which is being set the `Application.on_drag()` method in the example. This will be called whenever the user changes the selection area. This continues until the mouse button is released then the `MousePositionTracker.quit()` method gets called. – martineau Feb 09 '22 at 11:38
  • How should I call the `cur_selection()` ? Because I tried, but maybe I call it in the wrong way... – just_learning Feb 09 '22 at 13:07
  • 1
    @just_learning: It's a method of the `MousePositionTracker` class. The instance in my answer is the `self.posn_tracker` attribute of the `Application` class, so it could have been called via `self.posn_tracker.cur_selection()` — however it's not used in the example. Note that it won't work until the user has made a selection. Perhaps you should formally post a question with a [mre] if you can't get things working. – martineau Feb 09 '22 at 17:30
  • hi any way to add scroll region i tried self.canvas.config(scrollregion=self.canvas.bbox(tk.ALL)) but it doesn't seem to work – FaisalAlsalm Dec 24 '22 at 12:10
10

Here's an example of doing something like that with tkinter. After the first mouse-button click, the coordinates of the current selection area rectangle are in the globals topx, topy, botx, boty (before then, the global rect_id variable value will be None).

To use the selection rectangle, you will need to add something to the GUI, like a button or menu, that uses the current selection rectangle's location & size to create the thumbnail — you can get the coordinates of the selection rectangle by calling canvas.coords(rect_id). Note that PIL.Image instances have a thumbnail() method that provides a simple way to create one.

import tkinter as tk
from PIL import Image, ImageTk

WIDTH, HEIGHT = 900, 900
topx, topy, botx, boty = 0, 0, 0, 0
rect_id = None
path = "Books.jpg"


def get_mouse_posn(event):
    global topy, topx

    topx, topy = event.x, event.y

def update_sel_rect(event):
    global rect_id
    global topy, topx, botx, boty

    botx, boty = event.x, event.y
    canvas.coords(rect_id, topx, topy, botx, boty)  # Update selection rect.


window = tk.Tk()
window.title("Select Area")
window.geometry('%sx%s' % (WIDTH, HEIGHT))
window.configure(background='grey')

img = ImageTk.PhotoImage(Image.open(path))
canvas = tk.Canvas(window, width=img.width(), height=img.height(),
                   borderwidth=0, highlightthickness=0)
canvas.pack(expand=True)
canvas.img = img  # Keep reference in case this code is put into a function.
canvas.create_image(0, 0, image=img, anchor=tk.NW)

# Create selection rectangle (invisible since corner points are equal).
rect_id = canvas.create_rectangle(topx, topy, topx, topy,
                                  dash=(2,2), fill='', outline='white')

canvas.bind('<Button-1>', get_mouse_posn)
canvas.bind('<B1-Motion>', update_sel_rect)

window.mainloop()

Screenshot:

screenshot

You can download a copy of the Books.jpg image used by the code from here.

martineau
  • 119,623
  • 25
  • 170
  • 301
  • Thank you so much! How would I change it to make the canvas fixed size and the image fixed size as well regardless of its actual dimensions (scale the image to fit canvas size, basically). Additionally, is there any way to shade the area of the image outside the selection rectangle (like add red tint, or, at least, the default blue selection shade)? Also--not to be offtopic--but since you seem to be skilled with Python, any books/tutorials you could recommend in particular (I've never coded in Python before, and I'd like to learn it). Thank you again! – InfiniteLoop Apr 12 '19 at 16:14
  • 1
    InfiniteLoop: That's way too many follow-on questions for me to answer here in the comments—but I'll give you a few hints. See [What's the simplest way to resize an image to a given bounded area?](https://stackoverflow.com/questions/3873859/whats-the-simplest-way-to-resize-an-image-to-a-given-bounded-area) wrt resizing the image to fit. If you resize the image, you will need to "un-resize" the coordinates in the selection rectangle before using them to crop the original image. I'm not sure about the best way to shade the parts of the image. PIL can do such things, but that might be too slow. – martineau Apr 12 '19 at 16:25
  • 1
    ...there's _many_ online tutorials available for both Python and tkinter. I personal use [this](http://infohost.nmt.edu/tcc/help/pubs/tkinter/web/index.html) as a tkinter reference, although it's a little dated and doesn't cover several aspects of the module. It is however fairly well organized. The archetypical tkinter reference is [An Introduction to Tkinter](http://effbot.org/tkinterbook/) by Fredrik Lundh, its author. There's also the [Tkinter - Python Wiki](https://wiki.python.org/moin/TkInter). – martineau Apr 12 '19 at 16:40
  • I also _highly_ recommend you read (and start following) the advice in the accepted answer to [Is this bad programming practice in tkinter?](https://stackoverflow.com/questions/25454065/is-this-bad-programming-practice-in-tkinter), as well as [Best way to structure a tkinter application?](https://stackoverflow.com/questions/17466561/best-way-to-structure-a-tkinter-application) – martineau Apr 12 '19 at 16:49
  • I am trying to make the 2nd code of @martineau to run it when I press a button in a PyQt5 app. I put it in a callable class, but it does not seem to run. So I thought of making it a method (function). What modifications should I do in the above mentioned code to make it function and run it when I call it? – just_learning Feb 10 '22 at 14:55
  • 1
    @just_learning: `tkinter` is a Python binding to the Tk GUI toolkit, which is based on an [event-driven](https://en.wikipedia.org/wiki/Event-driven_programming) architecture — like most other GUIs — and is not procedural, so your request to "make it a function" doesn't make any sense. What you need to do is *adapt* it to the equivalent mechanisms the Qt GUI framework has. The code in my answer defines two "callback" functions that are each "bound" to a specific user-event. I believe Qt has something similar based on *signals* and *slots*, so do the equivalent using them. – martineau Feb 10 '22 at 17:56
  • 1
    ***Update*** Here's a working link to Fredrik Lundh's [An Introduction to Tkinter](https://web.archive.org/web/20201105231837id_/http://effbot.org/tkinterbook/) and another to the [Tkinter reference](https://tkdocs.com/shipman/) I've personally used the most. – martineau Feb 10 '22 at 18:24
  • @martineau: Thanks a lot for the help and the links!I have put: `return (event.x, event.y)` inside: `def get_mouse_posn(event):` function because it always keeps the (0,0) as a starting point. And via the returned values I need to update the global. How can I gather the returned values from here: `canvas.bind('', update_sel_rect)` ? – just_learning Feb 11 '22 at 09:32
  • 1
    `get_mouse_posn()` is designed to be called by `tkinter` when the user presses the left button on the mouse — `tkinter` doesn't care what it returns. Calling it directly yourself makes no sense. About all you can do is inside update some global variables the store the mouse position when the button was clicked — or may be have it call a some of function you wrote that does that. You would have to do something similar for the `'B1-Motion>'` callback (either update globals in `update_sel_rect()` or make it call a function that does. Sorry but this is the last comment I will be making to you. – martineau Feb 11 '22 at 09:42