R
Randy Smith
Hi! I'm looking for help with a Tkinter program's handling of resize.
I'm trying to do a fairly simple widget that shows a cropped part of a
larger image, and let's you navigate within the larger image through a
variety of methods. The widget hierarchy is:
root
ImageWidget (my class)
Label (contains the image)
Horizontal Scroll Bar
Vertical scroll bar
The cropping and scrolling works fine. But when I try to add
responding to resize events, I get into trouble. Specifically:
* When I naively change the size of the image shown to be borderwidth
less than the size indicated in the configure event, the size of the
image shown grows gradually but inexorably when I start the test
app. (Sorta scary, actually :-})
* When I fiddle a bit to figure out what the actual difference in size
is between the Configure event and the image that can be displayed,
I get a vibrating, jagged display of the image.
Investigation suggests that multiple configure events are hitting the
label in response to each user resize with different sizes. I'm
guessing that when I resize the image in response to those different
events, that creates new resize events propagating through the window
manager hierarchy, which creates new configure events, which means my
handler changes the image size, which ... you get the idea. However,
everything seems to work fine if I leave out the scroll bars and just
have a label in a frame inside the root window; the image resizes
fine. If the scroll bars are in place but I don't have the image
resize bound to the configure event, I get two sets of resize events
propagaing through the system on startup; without, I just get one.
Event lists and code included below. Any help would be appreciated.
Thanks!
-- Randy Smith
-- Event list on startup with scroll bars:
<receiving widget>: width height
root : 220 220
root : 1 1
iwidget : 220 220
root : 220 220
vscroll : 16 204
root : 16 204
hscroll : 204 16
root : 204 16
ilabel : 204 204
root : 204 204
vscroll : 15 205
root : 15 205
hscroll : 205 15
root : 205 15
ilabel : 205 205
root : 205 205
root : 219 219
ilabel : 205 205
root : 205 205
hscroll : 205 15
root : 205 15
vscroll : 15 205
root : 15 205
iwidget : 219 219
root : 219 219
vscroll : 15 204
root : 15 204
hscroll : 204 15
root : 204 15
ilabel : 204 204
root : 204 204
-- Event list on startup without scroll bars
root : 204 204
root : 1 1
iwidget : 204 204
root : 204 204
ilabel : 204 204
root : 204 204
-- Code, without image resize. If you want to see the vibration,
uncomment the line
self.label.bind("<Configure>", self.reconfigure, "+")
To actually run it you'll need an image "test.tiff" in the current
directory (any image of size > 200x200 will do) and access to the
python imaging library (PIL), but I hope the code is pretty clear
(other than the math transforming between various coordinate
systems, which I don't believe is relevant; focus on
reconfigure(), refresh, and __init__).
#!/usr/bin/python
import traceback
from Tkinter import *
from PIL import Image
import ImageTk
debug = 4
def display_args(*args):
print "Args: ", args
def display_event(event):
print event.__dict__
def display_tag_and_size(tag, event):
print tag, ": ", event.width, event.height
class NotYetImplemented(Exception): pass
def mapnum(x, fromrange, torange):
assert fromrange[0] <= x < fromrange[1], (fromrange[0], x,
fromrange[1])
assert torange[0] < torange[1], (torange[0], torange[1])
## Need to force floating point
x *= 1.0
return (x - fromrange[0]) / (fromrange[1] - fromrange[0]) *
(torange[1] - torange[0]) + torange[0]
class ImageWidget(Frame):
def __init__(self, parent, gfunc, image_size,
starting_zoom=1,
starting_ul=(0,0),
starting_size = None):
"""Create an Image Widget which will display an image based
on the
function passed. That function will be called with the
arguments
(zoom_factor, (xstart, xend), (ystart, yend)) and must return a
TkInter PhotoImage object of size (xend-xstart, yend-ystart).
IMAGE_SIZE describes the "base size" of the image being
backed by
gfunc.
starting_* describes the starting window on the image."""
## Default starting size to whole image
if not starting_size: starting_size = image_size
## Init parent
Frame.__init__(self, parent)
self.bind("<Configure>",
lambda e, t="iwidget": display_tag_and_size(t, e))
## Base image parameters
self.generator_func = gfunc
self.isize = image_size
## Modifier of base image size for coords currently working in
self.zoom = starting_zoom
## Interval of augmented (zoomed) image currently shown
## Note that these must be integers; these map directly to
pixels
self.xint = [starting_ul[0], starting_ul[0] + starting_size[0]]
self.yint = [starting_ul[1], starting_ul[1] + starting_size[1]]
## Widgets
self.label = Label(self)
print type(self.label["borderwidth"])
self.label.bind("<Configure>",
lambda e, t="ilabel": display_tag_and_size(t,
e))
self.labelborderwidth = 4 # XXX: Constant because I can't
manage
# to get the value of
# self.label["borderwidth"] as a number :-?
self.hscroll = Scrollbar(self, orient = HORIZONTAL,
command = lambda *args:
self.scmd(False, *args))
self.hscroll.bind("<Configure>",
lambda e, t="hscroll":
display_tag_and_size(t, e))
self.vscroll = Scrollbar(self, orient = VERTICAL,
command = lambda *args:
self.scmd(True, *args))
self.vscroll.bind("<Configure>",
lambda e, t="vscroll":
display_tag_and_size(t, e))
# self.label.bind("<Configure>", self.reconfigure, "+")
## Configure widgets
self.label.grid(row = 0, column = 0, sticky=N+S+E+W)
self.hscroll.grid(row = 1, column = 0, sticky=E+W)
self.vscroll.grid(row = 0, column = 1, sticky=N+S)
self.columnconfigure(0, weight=1)
self.rowconfigure(0, weight=1)
## And display
self.refresh()
def refresh(self):
"""Bring the image in the frame and the scroll bars in line
with the
current values."""
self.image = self.generator_func(self.zoom, self.xint,
self.yint)
self.label["image"] = self.image
## Map x&y interval into unit interval for scroll bars.
scroll_settings = (
(mapnum(self.xint[0],
(0, self.isize[0] * self.zoom),
(0, 1)),
mapnum(self.xint[1],
(0, self.isize[0] * self.zoom),
(0, 1))),
(mapnum(self.yint[0],
(0, self.isize[1] * self.zoom),
(0, 1)),
mapnum(self.yint[1],
(0, self.isize[1] * self.zoom),
(0, 1))))
if debug > 5:
print scroll_settings
self.hscroll.set(*scroll_settings[0])
self.vscroll.set(*scroll_settings[1])
def reconfigure(self, event):
print self.label["width"], self.label["height"], event.__dict__
self.xint[1] = min(self.xint[0]+event.width -
self.labelborderwidth,
int(self.isize[0]*self.zoom))
self.yint[1] = min(self.yint[0]+event.height -
self.labelborderwidth,
int(self.isize[1]*self.zoom))
self.refresh()
def scmd(self, isy, type, num, what = None):
"""Takes input args, changes either xint or yint, and calls
refresh to update the entire image."""
## Figure out interval to modify and base image size to work
off
if isy:
interval = self.yint
int_range = self.isize[1] * self.zoom
else:
interval = self.xint
int_range = self.isize[0] * self.zoom
## Figure out the width
int_width = interval[1] - interval[0]
## Transform input
num = float(num)
if type == MOVETO:
# num Describes the location of the low end of the slider
interval[0] = mapnum(num, (0, 1), (0, int_range))
elif type == SCROLL:
if what == "units":
interval[0] += num
else:
assert what == "pages", what
interval[0] += num * int_width
if interval[0] < 0: interval[0] = 0
if interval[0] > int_range - int_width:
interval[0] = int_range - int_width
interval[0] = int(interval[0])
interval[1] = interval[0] + int_width
assert type == MOVETO, type
if debug > 5:
print "yscroll" if isy else "xscroll", num, interval[0],
interval[1]
self.refresh()
## Room for optimization here; don't need to resize the whole image
def gfunc_for_image(image, zoom, xint, yint):
bbox = image.getbbox()
isize = (bbox[2] - bbox[0], bbox[3] - bbox[1])
ssize = (isize[0] * zoom, isize[1] * zoom)
if debug > 5:
print zoom, xint, yint, isize, ssize
ri = image.resize(ssize, Image.BILINEAR)
ci = ri.crop((xint[0],yint[0],xint[1],yint[1]))
return ImageTk.PhotoImage(ci)
def IWFromFile(parent, file, starting_size = None):
"Return an ImageWidget object based on an image on a file."
baseimage = Image.open(file)
(ulx, uly, lrx, lry) = baseimage.getbbox()
return ImageWidget(parent,
lambda z,xint,yint,i=baseimage:
gfunc_for_image(i, z, xint, yint),
(lrx - ulx, lry - uly),
starting_size = starting_size)
def IWFromImage(parent, img, starting_size = None):
"Return an imageWidget object based on a PIL image passed in."
(ulx, uly, lrx, lry) = img.getbbox()
return ImageWidget(parent,
lambda z,xint,yint,i=img: gfunc_for_image(i,
z, xint, yint),
(lrx - ulx, lry - uly),
starting_size = starting_size)
if __name__ == "__main__":
root = Tk()
root.resizable(True, True)
root.bind("<Configure>", lambda e, t="root":
display_tag_and_size(t, e))
iw = IWFromFile(root, "test.tiff", starting_size = (200, 200))
print "Gridding iwidget."
iw.grid(row=0,column=0,sticky=N+S+E+W)
print "Configuring root row."
root.rowconfigure(0, weight=1)
print "Configuring root column."
root.columnconfigure(0, weight=1)
print "Mainlooping."
iw.mainloop()
I'm trying to do a fairly simple widget that shows a cropped part of a
larger image, and let's you navigate within the larger image through a
variety of methods. The widget hierarchy is:
root
ImageWidget (my class)
Label (contains the image)
Horizontal Scroll Bar
Vertical scroll bar
The cropping and scrolling works fine. But when I try to add
responding to resize events, I get into trouble. Specifically:
* When I naively change the size of the image shown to be borderwidth
less than the size indicated in the configure event, the size of the
image shown grows gradually but inexorably when I start the test
app. (Sorta scary, actually :-})
* When I fiddle a bit to figure out what the actual difference in size
is between the Configure event and the image that can be displayed,
I get a vibrating, jagged display of the image.
Investigation suggests that multiple configure events are hitting the
label in response to each user resize with different sizes. I'm
guessing that when I resize the image in response to those different
events, that creates new resize events propagating through the window
manager hierarchy, which creates new configure events, which means my
handler changes the image size, which ... you get the idea. However,
everything seems to work fine if I leave out the scroll bars and just
have a label in a frame inside the root window; the image resizes
fine. If the scroll bars are in place but I don't have the image
resize bound to the configure event, I get two sets of resize events
propagaing through the system on startup; without, I just get one.
Event lists and code included below. Any help would be appreciated.
Thanks!
-- Randy Smith
-- Event list on startup with scroll bars:
<receiving widget>: width height
root : 220 220
root : 1 1
iwidget : 220 220
root : 220 220
vscroll : 16 204
root : 16 204
hscroll : 204 16
root : 204 16
ilabel : 204 204
root : 204 204
vscroll : 15 205
root : 15 205
hscroll : 205 15
root : 205 15
ilabel : 205 205
root : 205 205
root : 219 219
ilabel : 205 205
root : 205 205
hscroll : 205 15
root : 205 15
vscroll : 15 205
root : 15 205
iwidget : 219 219
root : 219 219
vscroll : 15 204
root : 15 204
hscroll : 204 15
root : 204 15
ilabel : 204 204
root : 204 204
-- Event list on startup without scroll bars
root : 204 204
root : 1 1
iwidget : 204 204
root : 204 204
ilabel : 204 204
root : 204 204
-- Code, without image resize. If you want to see the vibration,
uncomment the line
self.label.bind("<Configure>", self.reconfigure, "+")
To actually run it you'll need an image "test.tiff" in the current
directory (any image of size > 200x200 will do) and access to the
python imaging library (PIL), but I hope the code is pretty clear
(other than the math transforming between various coordinate
systems, which I don't believe is relevant; focus on
reconfigure(), refresh, and __init__).
#!/usr/bin/python
import traceback
from Tkinter import *
from PIL import Image
import ImageTk
debug = 4
def display_args(*args):
print "Args: ", args
def display_event(event):
print event.__dict__
def display_tag_and_size(tag, event):
print tag, ": ", event.width, event.height
class NotYetImplemented(Exception): pass
def mapnum(x, fromrange, torange):
assert fromrange[0] <= x < fromrange[1], (fromrange[0], x,
fromrange[1])
assert torange[0] < torange[1], (torange[0], torange[1])
## Need to force floating point
x *= 1.0
return (x - fromrange[0]) / (fromrange[1] - fromrange[0]) *
(torange[1] - torange[0]) + torange[0]
class ImageWidget(Frame):
def __init__(self, parent, gfunc, image_size,
starting_zoom=1,
starting_ul=(0,0),
starting_size = None):
"""Create an Image Widget which will display an image based
on the
function passed. That function will be called with the
arguments
(zoom_factor, (xstart, xend), (ystart, yend)) and must return a
TkInter PhotoImage object of size (xend-xstart, yend-ystart).
IMAGE_SIZE describes the "base size" of the image being
backed by
gfunc.
starting_* describes the starting window on the image."""
## Default starting size to whole image
if not starting_size: starting_size = image_size
## Init parent
Frame.__init__(self, parent)
self.bind("<Configure>",
lambda e, t="iwidget": display_tag_and_size(t, e))
## Base image parameters
self.generator_func = gfunc
self.isize = image_size
## Modifier of base image size for coords currently working in
self.zoom = starting_zoom
## Interval of augmented (zoomed) image currently shown
## Note that these must be integers; these map directly to
pixels
self.xint = [starting_ul[0], starting_ul[0] + starting_size[0]]
self.yint = [starting_ul[1], starting_ul[1] + starting_size[1]]
## Widgets
self.label = Label(self)
print type(self.label["borderwidth"])
self.label.bind("<Configure>",
lambda e, t="ilabel": display_tag_and_size(t,
e))
self.labelborderwidth = 4 # XXX: Constant because I can't
manage
# to get the value of
# self.label["borderwidth"] as a number :-?
self.hscroll = Scrollbar(self, orient = HORIZONTAL,
command = lambda *args:
self.scmd(False, *args))
self.hscroll.bind("<Configure>",
lambda e, t="hscroll":
display_tag_and_size(t, e))
self.vscroll = Scrollbar(self, orient = VERTICAL,
command = lambda *args:
self.scmd(True, *args))
self.vscroll.bind("<Configure>",
lambda e, t="vscroll":
display_tag_and_size(t, e))
# self.label.bind("<Configure>", self.reconfigure, "+")
## Configure widgets
self.label.grid(row = 0, column = 0, sticky=N+S+E+W)
self.hscroll.grid(row = 1, column = 0, sticky=E+W)
self.vscroll.grid(row = 0, column = 1, sticky=N+S)
self.columnconfigure(0, weight=1)
self.rowconfigure(0, weight=1)
## And display
self.refresh()
def refresh(self):
"""Bring the image in the frame and the scroll bars in line
with the
current values."""
self.image = self.generator_func(self.zoom, self.xint,
self.yint)
self.label["image"] = self.image
## Map x&y interval into unit interval for scroll bars.
scroll_settings = (
(mapnum(self.xint[0],
(0, self.isize[0] * self.zoom),
(0, 1)),
mapnum(self.xint[1],
(0, self.isize[0] * self.zoom),
(0, 1))),
(mapnum(self.yint[0],
(0, self.isize[1] * self.zoom),
(0, 1)),
mapnum(self.yint[1],
(0, self.isize[1] * self.zoom),
(0, 1))))
if debug > 5:
print scroll_settings
self.hscroll.set(*scroll_settings[0])
self.vscroll.set(*scroll_settings[1])
def reconfigure(self, event):
print self.label["width"], self.label["height"], event.__dict__
self.xint[1] = min(self.xint[0]+event.width -
self.labelborderwidth,
int(self.isize[0]*self.zoom))
self.yint[1] = min(self.yint[0]+event.height -
self.labelborderwidth,
int(self.isize[1]*self.zoom))
self.refresh()
def scmd(self, isy, type, num, what = None):
"""Takes input args, changes either xint or yint, and calls
refresh to update the entire image."""
## Figure out interval to modify and base image size to work
off
if isy:
interval = self.yint
int_range = self.isize[1] * self.zoom
else:
interval = self.xint
int_range = self.isize[0] * self.zoom
## Figure out the width
int_width = interval[1] - interval[0]
## Transform input
num = float(num)
if type == MOVETO:
# num Describes the location of the low end of the slider
interval[0] = mapnum(num, (0, 1), (0, int_range))
elif type == SCROLL:
if what == "units":
interval[0] += num
else:
assert what == "pages", what
interval[0] += num * int_width
if interval[0] < 0: interval[0] = 0
if interval[0] > int_range - int_width:
interval[0] = int_range - int_width
interval[0] = int(interval[0])
interval[1] = interval[0] + int_width
assert type == MOVETO, type
if debug > 5:
print "yscroll" if isy else "xscroll", num, interval[0],
interval[1]
self.refresh()
## Room for optimization here; don't need to resize the whole image
def gfunc_for_image(image, zoom, xint, yint):
bbox = image.getbbox()
isize = (bbox[2] - bbox[0], bbox[3] - bbox[1])
ssize = (isize[0] * zoom, isize[1] * zoom)
if debug > 5:
print zoom, xint, yint, isize, ssize
ri = image.resize(ssize, Image.BILINEAR)
ci = ri.crop((xint[0],yint[0],xint[1],yint[1]))
return ImageTk.PhotoImage(ci)
def IWFromFile(parent, file, starting_size = None):
"Return an ImageWidget object based on an image on a file."
baseimage = Image.open(file)
(ulx, uly, lrx, lry) = baseimage.getbbox()
return ImageWidget(parent,
lambda z,xint,yint,i=baseimage:
gfunc_for_image(i, z, xint, yint),
(lrx - ulx, lry - uly),
starting_size = starting_size)
def IWFromImage(parent, img, starting_size = None):
"Return an imageWidget object based on a PIL image passed in."
(ulx, uly, lrx, lry) = img.getbbox()
return ImageWidget(parent,
lambda z,xint,yint,i=img: gfunc_for_image(i,
z, xint, yint),
(lrx - ulx, lry - uly),
starting_size = starting_size)
if __name__ == "__main__":
root = Tk()
root.resizable(True, True)
root.bind("<Configure>", lambda e, t="root":
display_tag_and_size(t, e))
iw = IWFromFile(root, "test.tiff", starting_size = (200, 200))
print "Gridding iwidget."
iw.grid(row=0,column=0,sticky=N+S+E+W)
print "Configuring root row."
root.rowconfigure(0, weight=1)
print "Configuring root column."
root.columnconfigure(0, weight=1)
print "Mainlooping."
iw.mainloop()