Tkinter画布缩放+移动/平移 [英] Tkinter canvas zoom + move/pan
问题描述
Tkinter的画布小部件具有以下内置功能:
-
移动/平移画布(例如,单击并拖动) )和
canvas.scan_mark
和canvas.scan_dragto
,请参见。
在Windows 7 64位系统上测试和Python 3.6.2。
不要忘记在脚本的末尾放置图像的路径。
#-*-编码:utf-8-*-
#高级缩放示例。就像在Google地图中一样。
#它只会缩放一个图块,而不缩放整个图像。因此,缩放后的图块会占用
#不变的内存,而不会在大缩放时使用巨大的调整大小的图像填充该图块。
导入随机
从tkinter导入tkinter as tk
导入ttk
从PIL导入Image,ImageTk
class AutoScrollbar(ttk.Scrollbar):
'''滚动条,在不需要时会自动隐藏。
仅在使用网格几何管理器'''
def set(self,lo,hi)时有效:如果float(lo)< = 0.0且float(hi)>
= 1.0:
self.grid_remove()
其他:
self.grid()
ttk.Scrollbar.set(self,lo,hi)
def pack(self,** kw):
提高tk.TclError('不能在此小部件中使用pack')
def place(self,** kw):
提高tk.TclError('不能在此小部件中使用位置')
class Zoom_Advanced(ttk.Frame):
'''图像的高级缩放'''
def __init __(self,mainframe,path):
'''初始化主框架'''
ttk.Frame .__ init __(self,master = mainframe)
self.master.title( '使用鼠标滚轮缩放')
#画布的垂直和水平滚动条
vbar = AutoScrollbar(self.master,orient ='vertical')
hbar = AutoScrollbar(self.master,orient = '水平')
vbar.grid(row = 0,column = 1,sticky ='ns')
hbar.grid(row = 1,column = 0,sticky ='we')
#创建画布并放置图像在它上
self.canvas = tk.Canvas(self.master,highlightthickness = 0,
xscrollcommand = hbar.set,yscrollcommand = vbar.set)
self.canvas.grid(row = 0,column = 0,sticky ='nswe')
self.canvas.update()#等待创建画布
vbar.configure(command = self.scroll_y)#将滚动条绑定到画布
hbar.configure(command = self.scroll_x)
#使画布可扩展
self.master.rowconfigure(0,weight = 1)
self.master.columnconfigure(0, weight = 1)
#将事件绑定到画布
self.canvas.bind('< Configure>',self.show_image)#调整画布大小
self.canvas.bind(' < ButtonPress-1>',self.move_from)
self.canvas.bind('< B1-Motion>',self.move_to)
self.c anvas.bind('< MouseWheel>',self.wheel)#仅适用于Windows和MacOS,而不适用于Linux
self.canvas.bind('< Button-5>',self.wheel)#仅适用于Linux,仅向下滚动
self.canvas.bind('< Button-4>',self.wheel)#仅适用于Linux,向上滚动
self.image = Image.open(path) #打开图像
self.width,self.height = self.image.size
self.imscale = 1.0#Canvaas图像的缩放比例
self.delta = 1.3#缩放幅度
#将图像放入容器矩形,并使用它为图像设置适当的坐标
self.container = self.canvas.create_rectangle(0,0,self.width,self.height,width = 0)
#为测试目的绘制一些可选的随机矩形
minsize,maxsize,number = 5,20,10
对于range(number)中的n:
x0 = random.randint(0, self.width-maxsize)
y0 = random.randint(0,self.height-maxsize)
x1 = x0 + random.randint(最小尺寸,最大尺寸)
y1 = y0 + random.randint(最小尺寸,最大尺寸)
color =('red','orange','yellow','green' ,'blue')[random.randint(0,4)]
self.canvas.create_rectangle(x0,y0,x1,y1,fill = color,activefill ='black')
self.show_image ()
def scroll_y(self,* args,** kwargs):
'''垂直滚动画布并重新绘制图像'''
self.canvas.yview( * args,** kwargs)#垂直滚动
self.show_image()#重绘图像
def scroll_x(self,* args,** kwargs):
'' '水平滚动画布并重绘图像'''
self.canvas.xview(* args,** kwargs)#水平滚动
self.show_image()#重绘图像
def move_from(self,event):
'''记住以前使用鼠标滚动的坐标'''
self.canvas.scan_mark(event.x,event.y)
def move_to(self,event):
'''将画布移动(移动)到新位置'''
self.canvas.scan_dragto(event.x,event.y,gain = 1)
self.show_image()#重绘图像
def wheel(self,event):
'''用鼠标滚轮缩放'''
x = self。 canvas.canvasx(event.x)
y = self.canvas.canvasy(event.y)
bbox = self.canvas.bbox(self.container)#如果bbox是获取图像区域
[ 0]< x < bbox [2]和bbox [1]< & bbox [3]:通过#好!在图像
内部:return#仅在图像区域
缩放= 1.0
#在以下情况下响应Linux(event.num)或Windows(event.delta)滚轮事件
event.num == 5或event.delta == -120:#向下滚动
i = min(self.width,self.height)
如果int(i * self.imscale)< 30:返回#图像小于30像素
self.imscale / = self.delta
scale / = self.delta
如果event.num == 4或event.delta == 120 :#向上滚动
i = min(self.canvas.winfo_width(),self.canvas.winfo_height())
如果我< self.imscale:返回#1个像素大于可见区域
self.imscale * = self.delta
scale * = self.delta
self.canvas.scale('all', x,y,scale,scale)#重新缩放所有画布对象
self.show_image()
def show_image(self,event = None):
'''显示图像于Canvas'''
bbox1 = self.canvas.bbox(self.container)#获取图像区域
#删除bbox1两侧的像素偏移
bbox1 =(bbox1 [0 ] + 1,bbox1 [1] + 1,bbox1 [2]-1,bbox1 [3]-1)
bbox2 =(self.canvas.canvasx(0),#获取画布的可见区域
self.canvas.canvasy(0),
self.canvas.canvasx(self.canvas.winfo_width()),
self.canvas.canvasy(self.canvas.winfo_height()))
bbox = [min(bbox1 [0],bbox2 [0]),min(bbox1 [1],bbox2 [1]),#获取滚动区域框
max(bbox1 [2],bbox2 [ 2]),max(bbox1 [3], bbox2 [3])]
如果bbox [0] == bbox2 [0]和bbox [2] == bbox2 [2]:#可见区域中的整个图像
bbox [0] = bbox1 [0]
bbox [2] = bbox1 [2]
如果bbox [1] == bbox2 [1]和bbox [3] == bbox2 [3]:#整个可见区域中的图像
bbox [1] = bbox1 [1]
bbox [3] = bbox1 [3]
self.canvas.configure(scrollregion = bbox)#设置滚动区域
x1 = max(bbox2 [0]-bbox1 [0],0)#获取图像图块
y1 = max(bbox2 [1]-bbox1 [1],0)的坐标(x1,y1,x2,y2)
x2 =最小值(bbox2 [2],bbox1 [2])-bbox1 [0]
y2 =最小值(bbox2 [3],bbox1 [3])-bbox1 [1]
如果int(x2-x1)> 0并且int(y2-y1)> 0:#显示图像是否在可见区域
x = min(int(x2 / self.imscale),self.width)#有时它在1个像素上更大...
y = min(int (y2 / self.imscale),self.height)#...有时不是
image = self.image.crop((int(x1 / self.imscale),int(y1 / self.imscale), x,y))
imagetk = ImageTk.PhotoImage(image.resize((int(x2-x1),int(y2-y1)))))
imageid = self.canvas.create_image(max( bbox2 [0],bbox1 [0]),max(bbox2 [1],bbox1 [1]),
anchor ='nw',image = imagetk)
self.canvas.lower(imageid) #将图像设置为背景
self.canvas.imagetk = imagetk#保留额外的引用以防止垃圾收集
path ='doge.jpg'#在此处放置图像的路径
root = tk.Tk()
app = Zoom_Advanced(root,path = path)
root.mainloop()
编辑:
我创建了ev zh更高级的变焦。存在图像金字塔。
版本3.0在Windows 7 64位和Python 3.7上进行了测试。
#-*-编码:utf-8-*-
#先进的缩放功能,可处理从小到大到几GB的各种类型的图像
进口数学
进口警告
进口tkinter as tk
从tkinter进口ttk
从PIL进口Image,ImageTk
类AutoScrollbar (ttk.Scrollbar):
。不需要时会隐藏的滚动条。仅适用于网格几何管理器
def set(self,lo,hi):如果float(lo)< = 0.0且float(hi)> = 1.0:
self.grid_remove()
else:
self.grid()
ttk.Scrollbar.set(self,lo,hi)
def pack(self,** kw):
加薪tk.TclError('不能与小部件一起使用包'+ self .__ class __.__ name__)
def place(self,** kw):
提高tk.TclError('不能使用place加上小部件'+ self .__ class __.__ name__)
class CanvasImage:
显示和缩放图像。
def __init __(自己,占位符,路径):
初始化ImageFrame。
self.imscale = 1.0#画布图像缩放比例,对于外部类是公开的。
self .__ delta = 1.3#缩放幅度
self .__ filter = Image.ANTIALIAS#可以是:NEAREST, BILINEAR,BICUBIC和ANTIALIAS
self .__ previous_state = 0#键盘的先前状态
self.path = path#图像的路径,应该对外部类是公用的
#在占位符中创建ImageFrame小部件
self .__ imframe = ttk.Frame(placeholder)#ImageFrame对象的占位符
#画布的垂直和水平滚动条
hbar = AutoScrollbar(self .__ imframe,orient ='horizontal')
vbar = AutoScrollbar(self .__ imframe,orient ='vertical')
hbar.grid(row = 1,column = 0,sticky ='we')
vbar.grid(row = 0,column = 1,sticky ='ns')
#创建画布并用滚动条绑定它。外部类的公共类
self.canvas = tk.Canvas(self .__ imframe,highlightthickness = 0,
xscrollcommand = hbar.set,yscrollcommand = vbar.set)
self.canvas.grid( row = 0,column = 0,sticky ='nswe')
self.canvas.update()#等待创建画布
hbar.configure(command = self .__ scroll_x)#将滚动条绑定到canvas
vbar.configure(command = self .__ scroll_y)
#将事件绑定到Canvas
self.canvas.bind('< Configure>',lambda事件:self .__ show_image() )#调整画布大小
self.canvas.bind('< ButtonPress-1>',self .__ move_from)#记住画布位置
self.canvas.bind('< B1-Motion>' ,self .__ move_to)#将画布移到新位置
self.canvas.bind('< MouseWheel>',self .__ wheel)#适用于Windows和MacOS的缩放,但不适用于Linux
self.canvas .bind('< Button-5>',self .__ wheel)#缩放林ux,向下滚动
self.canvas.bind('< Button-4>',self .__ wheel)#为Linux缩放,向上滚动
#在空闲模式下处理击键,因为程序变慢在功能较弱的计算机上运行
#,同时发生太多按键事件
self.canvas.bind('< Key>',lambda事件:self.canvas.after_idle(self .__ keystroke ,event))
#决定此图像是否巨大
self .__ huge = False#巨大或否
self .__ huge_size = 14000#定义巨大图像
self的大小.__ band_width = 1024#瓷砖区域的宽度
Image.MAX_IMAGE_PIXELS = 1000000000#使用警告警告抑制大图像
的DecompressionBombError.catch_warnings():#禁止DecompressionBombWarning
warnings.simplefilter('ignore ')
self .__ image = Image.open(self.path)#打开图像,但不加载它
self.imwidth,self.imheight = self.__image.si ze#公开外部类
,如果self.imwidth * self.imheight> self .__ huge_size * self .__ huge_size和\
self .__ image.tile [0] [0] =='raw':#仅原始图像可以平铺
self .__ huge = True#图像是巨大的
self .__ offset = self .__ image.tile [0] [2]#初始图块偏移量
self .__ tile = [self .__ image.tile [0] [0],# 'raw'
[0,0,self.imwidth,0],#切片范围(矩形)
self .__ offset,
self .__ image.tile [0] [3]] #解码器的参数列表
self .__ min_side = min(self.imwidth,self.imheight)#获取较小的图像面
#创建图像金字塔
self .__ pyramid = [自我。更小()]如果self .__ huge else [Image.open(self.path)]
#设置图像金字塔
self .__ ratio = max(self.imwidth,self.imheight)/ self的比率系数.__ huge_size if self .__ huge else 1.0
self .__ curr_img = 0#当前图像金字塔
self .__ scale = self.imscale * self .__ ratio#图像金字塔比例
self .__ reduction = 2#图像金字塔
w的还原度,h =自我。__pyramid [-1] .size
而w> 512且h> 512:#顶部金字塔图像的大小约为512像素
w / = self .__ reduction#缩小度划分
h / = self .__ reduction#缩小度划分
self .__ pyramid.append( self .__ pyramid [-1] .resize((int(w),int(h)),self .__ filter))
#将图像放入容器矩形并使用它为图像$ b $设置适当的坐标b self.container = self.canvas.create_rectangle((0,0,self.imwidth,self.imheight),width = 0)
self .__ show_image()#在画布
self上显示图像。 canvas.focus_set()#将焦点设置在画布上
def small(self):
按比例调整图像大小,并返回较小的图像。
w1,h1 = float(self.imwidth),float(self.imheight)
w2,h2 = float(self .__ huge_size),float(self .__ huge_size)
Aspect_ratio1 = w1 / h1
Aspect_ratio2 = w2 / h2#如果Aspect_ratio1 == Aspect_ratio2则等于1.0
:
image = Image.new('RGB',(int(w2),int(h2)) )
k = h2 / h1#压缩率
w = int(w2)#带宽
elif Aspect_ratio1> Aspect_ratio2:
image = Image.new('RGB',(int(w2),int(w2 / aspect_ratio1)))
k = h2 / w1#压缩率
w = int(w2) #band length
else:#Aspect_ratio1< Aspect_ration2
image = Image.new('RGB',(int(h2 * Aspect_ratio1),int(h2)))
k = h2 / h1#压缩率
w = int(h2 * Aspect_ratio1 )#频带长度
i,j,n = 0,1,圆(0.5 + self.imheight / self .__ band_width)
而i< self.imheight:
print('\rOpening image:{j} from {n}'。format(j = j,n = n),end ='')
band = min(self .__ band_width,self.imheight-i)#瓦片频带
self .__ tile [1] [3] = band#设置频带宽度
self .__ tile [2] = self .__ offset + self .imwidth * i * 3#瓦片偏移量(每个像素3个字节)
self .__ image.close()
self .__ image = Image.open(self.path)#重新打开/重置图像
self .__ image.size =(self.imwidth,band)#设置图块带的大小
self .__ image.tile = [self .__ tile]#设置图块
裁剪= self .__ image.crop (((0,0,self.imwidth,band))#裁剪图带
image.paste(cropped.resize((w,int(band * k)+1),self .__ filter),(0, int(i * k)))
i + = band
j + = 1
print('\r'+ 30 *''+'\r',end ='') #隐藏打印的字符串
返回图像
def redraw_figures(self ):
。虚拟功能可在子类中重绘图形。
pass
def grid(self,** kw):
将CanvasImage窗口小部件放在父窗口小部件上。
self .__ imframe.grid(** kw)#将CanvasImage小部件放置在网格上
self .__ imframe.grid(sticky ='nswe')#使框架容器变为粘性
self .__ imframe。 rowconfigure(0,weight = 1)#使canvas可扩展
self .__ imframe.columnconfigure(0,weight = 1)
def pack(self,** kw):
例外:不能将此包与该小部件一起使用。
引发异常(‘不能与小部件’+ self一起使用pack。__class __.__ name__)
def place(self,** kw):
例外:不能将此地方与该小部件一起使用。
引发异常('不能在控件中使用位置'+ self .__ class __.__ name__)
#noinspection PyUnusedLocal
def __scroll_x(self,* args,** kwargs):
。水平滚动画布,然后重新绘制图像。
self.canvas.xview(* args)#水平滚动
self .__ show_image()#重绘图像
#noinspection PyUnusedLocal
def __scroll_y(self,* args,** kwargs):
垂直滚动画布,然后重新绘制图像。
self.canvas.yview(* args)#垂直滚动
self .__ show_image()#重绘图像
def __show_image(self):
;"在画布上显示图像。实现正确的图像缩放,几乎就像在Google Maps
box_image = self.canvas.coords(self.container)#获取图像区域
box_canvas =(self.canvas.canvasx(0),#获取画布的可见区域
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))#转换为整数,否则将无法正常运行
#获取滚动区域框
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])]
#如果box_scroll [0] == box_canvas [0]和box_scroll [2] == box_canvas [2],则图像位于可见区域
中:
box_scroll [0] = box_img_int [0]
box_scroll [2] = box_img_int [2]
#图像的垂直部分是i n如果box_scroll [1] == box_canvas [1]和box_scroll [3] == box_canvas [3],则可见区域
:
box_scroll [1] = box_img_int [1]
box_scroll [ 3] = box_img_int [3]
#将滚动区域转换为元组和整数
self.canvas.configure(scrollregion = tuple(map(int,box_scroll)))#设置滚动区域
x1 = max(box_canvas [0]-box_image [0],0)#获取图像图块
的坐标(x1,y1,x2,y2)
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] $ b如果int(x2-x1)> 0并且int(y2-y1)> 0:#显示图像是否在可见区域
中,如果self .__ huge和self .__ curr_img< 0:#显示大图像
h = int((y2-y1)/ self.imscale)#瓦片带的高度
self .__ tile [1] [3] = h#设置瓦片带的高度
self .__ tile [2] = self .__ offset + self.imwidth * int(y1 / self.imscale)* 3
self .__ image.close()
self .__ image =图片。 open(self.path)#重新打开/重置图像
self .__ image.size =(self.imwidth,h)#设置图块的大小
self .__ image.tile = [self .__ tile]
image = self .__ image.crop((int(x1 / self.imscale),0,int(x2 / self.imscale),h))
else:#显示普通图像
图像= self .__ pyramid [max(0,self .__ curr_img)]。crop(#从金字塔
中裁剪当前img(int(x1 / self .__ scale),int(y1 / self .__ scale),
int(x2 / self .__ scale),int(y2 / self .__ scale)))
#
imagetk = ImageTk.PhotoImage(image.resize((int(x2-x1),int(y2-y1)),self .__ filter))
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)#将图像设置为背景
self.canvas.imagetk = imagetk#保留额外的引用以防止垃圾收集
def __move_from(self,event):
;记住先前使用鼠标滚动的坐标。
self.canvas.scan_mark(event.x,event.y)
def __move_to(self,event):
将画布拖动(移动)到新位置。
self.canvas.scan_dragto(event.x,event.y,gain = 1)
self .__ show_image()#缩放并显示在画布上
def在外面(self,x,y):
。检查点(x,y)是否在图像区域内。
bbox = self.canvas.coords(self.container)#如果bbox [0]< x < bbox [2]和bbox [1]< & bbox [3]:
返回False#点(x,y)在图像区域
其他:
返回True#点(x,y)在图像区域$ b $外部b
def __wheel(自身,事件):
用鼠标滚轮进行缩放。
x = self.canvas.canvasx(event.x)#获取事件在画布上的坐标
y = self.canvas.canvasy(event.y)
if self.outside(x, y):返回#仅在图像区域内缩放
scale = 1.0
#如果event.num == 5则响应Linux(event.num)或Windows(event.delta)滚轮事件
或event.delta == -120:#向下滚动,如果round(self .__ min_side * self.imscale)<较小,则
30:返回#图像小于30像素
self.imscale / = self .__ delta
scale / = self .__ delta
如果event.num == 4或event.delta == 120 :#向上滚动,更大
i = min(self.canvas.winfo_width(),self.canvas.winfo_height())>>如果我< 1
self.imscale:返回#1个像素大于可见区域
self.imscale * = self .__ delta
标度* = self .__ delta
#从金字塔$ b $中获取适当的图像bk = self.imscale * self .__ ratio#临时系数
self .__ curr_img = min((-1)* int(math.log(k,self .__ reduction)),len(self .__ pyramid)-1)
self .__ scale = k * math.pow(self .__ reduction,max(0,self .__ curr_img))
#
self.canvas.scale('all',x,y,缩放,缩放)#重新缩放所有对象
#重新绘制一些图形,然后在屏幕上显示图像
self.redraw_figures()#子类
self的方法。__show_image()
def __击键(自己,事件):
用键盘滚动。
独立于键盘,CapsLock,< Ctrl> +<键>等语言。
if event.state-self .__ previous_state == 4:#表示按下Control键
通过#如果按下Control键则不执行
else:
self .__ previous_state = event.state#记住上一个按键状态
#如果[68,39,102]中的event.keycode,向上,向下,向左,向右按键
:#向右滚动:键'D','向右或数字键6
self .__ scroll_x('scroll',1,'unit',event = event)
elif event.keycode in [65,37,100]:#向左滚动:键 A,左或数字键4
self .__ scroll_x('scroll',-1,'unit',event = event)
elif event.keycode in [87, 38,104]:#向上滚动:键'W','Up'或'Numpad-8'
self .__ scroll_y('scroll',-1,'unit',event = event)
elif event.keycode in [83,40,98]:#向下滚动:键'S','Down'或'Numpad-2'
self .__ scroll_y('scroll',1,'unit',甚至t = event)
def作物(self,bbox):
从图像中裁剪矩形,然后将其返回
如果self .__ huge:#图像很大,并且不完全在RAM中
band = bbox [3]-bbox [1]#瓦片带的宽度
self .__ tile [1] [ 3] = band#设置瓦片高度
self .__ tile [2] = self .__ offset + self.imwidth * bbox [1] * 3#设置频段
self .__ image.close( )
self .__ image = Image.open(self.path)#重新打开/重置图像
self .__ image.size =(self.imwidth,band)#设置图块带
的大小self .__ image.tile = [self .__ tile]
返回self .__ image.crop((bbox [0],0,bbox [2],band))
其他:#图像完全在RAM中
return self .__ pyramid [0] .crop(bbox)
def destroy(self):
ImageFrame析构函数
self .__ image.close()
map(lambda i:i.close,self .__ pyramid)#关闭所有金字塔图像
del self .__ pyramid [:]#删除金字塔列表
del self .__ pyramid#删除金字塔变量
self.canvas.destroy()
self .__ imframe.destroy()
class MainWindow(ttk.Frame):
主窗口类
def __init __(自身,大型机,路径):
初始化主框架。
ttk.Frame .__ init __(self,master = mainframe)
self.master.title('Advanced Zoom v3.0')
self.master.geometry('800x600')#size
self.master.rowconfigure(0,weight = 1)#使CanvasImage小部件可扩展
self.master.columnconfigure(0,weight = 1)
canvas = CanvasImage( self.master,path)#创建部件
canvas.grid(row = 0,column = 0)#显示部件
filename ='./data/img_plg5.png'#放置路径到您的图片
#filename ='d:/Data/yandex_z18_1-1.tif'#巨大的TIFF文件1.4 GB
#filename ='d:/Data/The_Garden_of_Earthly_Delights_by_Bosch_High_Resolution.jpg'
#filename ='d:/Data/The_Garden_of_Earthly_Delights_by_Bosch_High_Resolution.tif'
#filename ='d:/Data/heic1502a.tif'
#filename ='d:/Data/land_shallow_topo_east.tif'
#filename ='d:/Data/X1D5_B0002594.3FR'
app = MainWindow(tk.Tk(),path = filename)
app.mainloop()
这是使用高级缩放功能的GitHub应用程序,用于带有多边形的手动图像批注。
Tkinter's canvas widget has built-in features to:
move/pan the canvas (for example with Click + Drag) with
canvas.scan_mark
andcanvas.scan_dragto
, see this questionzoom the vector elements on the canvas with
canvas.scale
, but sadly, this doesn't work for bitmap images on the canvas
Fortunately, this method allows zooming of images (by manually redrawing the zoomed portion of the image). But:
As we are redrawing a particular portion of the canvas, move/pan feature won't work anymore...
We absolutely need to render more than the currently displayed area, to allow move/pan. Let's say we have 1000x1000 bitmap on the canvas, and we want to zoom by a factor 50x... How to avoid having a 50.000 x 50.000 pixels bitmap in memory? (2.5 gigapixels in RAM is too big). We could think about rendering the viewport only, or a bit more than the current viewport to allow panning, but then what to do once panning leads to the edge of the rendered zone?
How to have a move/pan + zoom feature on Tkinter canvas, that works for images?
解决方案Advanced zoom example. Like in Google Maps.
Example video (longer video here):
It zooms only a tile, but not the whole image. So the zoomed tile occupies constant memory and not crams it with a huge resized image for the large zooms. For the simplified zoom example look here.
Tested on Windows 7 64-bit and Python 3.6.2.
Do not forget to place a path to your image at the end of the script.
# -*- coding: utf-8 -*- # Advanced zoom example. Like in Google Maps. # It zooms only a tile, but not the whole image. So the zoomed tile occupies # constant memory and not crams it with a huge resized image for the large zooms. import random import tkinter as tk from tkinter import ttk from PIL import Image, ImageTk class AutoScrollbar(ttk.Scrollbar): ''' A scrollbar that hides itself if it's not needed. Works only if you use the 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) def pack(self, **kw): raise tk.TclError('Cannot use pack with this widget') def place(self, **kw): raise tk.TclError('Cannot use place with this widget') class Zoom_Advanced(ttk.Frame): ''' Advanced zoom of the image ''' def __init__(self, mainframe, path): ''' Initialize the main Frame ''' ttk.Frame.__init__(self, master=mainframe) self.master.title('Zoom with mouse wheel') # Vertical and horizontal scrollbars for canvas vbar = AutoScrollbar(self.master, orient='vertical') hbar = AutoScrollbar(self.master, orient='horizontal') vbar.grid(row=0, column=1, sticky='ns') hbar.grid(row=1, column=0, sticky='we') # Create canvas and put image on it self.canvas = tk.Canvas(self.master, highlightthickness=0, xscrollcommand=hbar.set, yscrollcommand=vbar.set) self.canvas.grid(row=0, column=0, sticky='nswe') self.canvas.update() # wait till canvas is created vbar.configure(command=self.scroll_y) # bind scrollbars to the canvas hbar.configure(command=self.scroll_x) # Make the canvas expandable self.master.rowconfigure(0, weight=1) self.master.columnconfigure(0, weight=1) # Bind events to the Canvas self.canvas.bind('<Configure>', self.show_image) # canvas is resized self.canvas.bind('<ButtonPress-1>', self.move_from) self.canvas.bind('<B1-Motion>', self.move_to) self.canvas.bind('<MouseWheel>', self.wheel) # with Windows and MacOS, but not Linux self.canvas.bind('<Button-5>', self.wheel) # only with Linux, wheel scroll down self.canvas.bind('<Button-4>', self.wheel) # only with Linux, wheel scroll up self.image = Image.open(path) # open image self.width, self.height = self.image.size self.imscale = 1.0 # scale for the canvaas image self.delta = 1.3 # zoom magnitude # Put image into container rectangle and use it to set proper coordinates to the image self.container = self.canvas.create_rectangle(0, 0, self.width, self.height, width=0) # Plot some optional random rectangles for the test purposes minsize, maxsize, number = 5, 20, 10 for n in range(number): x0 = random.randint(0, self.width - maxsize) y0 = random.randint(0, self.height - maxsize) x1 = x0 + random.randint(minsize, maxsize) y1 = y0 + random.randint(minsize, maxsize) color = ('red', 'orange', 'yellow', 'green', 'blue')[random.randint(0, 4)] self.canvas.create_rectangle(x0, y0, x1, y1, fill=color, activefill='black') self.show_image() def scroll_y(self, *args, **kwargs): ''' Scroll canvas vertically and redraw the image ''' self.canvas.yview(*args, **kwargs) # scroll vertically self.show_image() # redraw the image def scroll_x(self, *args, **kwargs): ''' Scroll canvas horizontally and redraw the image ''' self.canvas.xview(*args, **kwargs) # scroll horizontally self.show_image() # redraw the image def move_from(self, event): ''' Remember previous coordinates for scrolling with the mouse ''' self.canvas.scan_mark(event.x, event.y) def move_to(self, event): ''' Drag (move) canvas to the new position ''' self.canvas.scan_dragto(event.x, event.y, gain=1) self.show_image() # redraw the image def wheel(self, event): ''' Zoom with mouse wheel ''' x = self.canvas.canvasx(event.x) y = self.canvas.canvasy(event.y) bbox = self.canvas.bbox(self.container) # get image area if bbox[0] < x < bbox[2] and bbox[1] < y < bbox[3]: pass # Ok! Inside the image else: 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: # scroll down i = min(self.width, self.height) if int(i * self.imscale) < 30: return # image is less than 30 pixels self.imscale /= self.delta scale /= self.delta if event.num == 4 or event.delta == 120: # scroll up i = min(self.canvas.winfo_width(), self.canvas.winfo_height()) if i < self.imscale: return # 1 pixel is bigger than the visible area self.imscale *= self.delta scale *= self.delta self.canvas.scale('all', x, y, scale, scale) # rescale all canvas objects self.show_image() def show_image(self, event=None): ''' Show image on the Canvas ''' bbox1 = self.canvas.bbox(self.container) # get image area # Remove 1 pixel shift at the sides of the bbox1 bbox1 = (bbox1[0] + 1, bbox1[1] + 1, bbox1[2] - 1, bbox1[3] - 1) bbox2 = (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())) bbox = [min(bbox1[0], bbox2[0]), min(bbox1[1], bbox2[1]), # get scroll region box max(bbox1[2], bbox2[2]), max(bbox1[3], bbox2[3])] if bbox[0] == bbox2[0] and bbox[2] == bbox2[2]: # whole image in the visible area bbox[0] = bbox1[0] bbox[2] = bbox1[2] if bbox[1] == bbox2[1] and bbox[3] == bbox2[3]: # whole image in the visible area bbox[1] = bbox1[1] bbox[3] = bbox1[3] self.canvas.configure(scrollregion=bbox) # set scroll region x1 = max(bbox2[0] - bbox1[0], 0) # get coordinates (x1,y1,x2,y2) of the image tile y1 = max(bbox2[1] - bbox1[1], 0) x2 = min(bbox2[2], bbox1[2]) - bbox1[0] y2 = min(bbox2[3], bbox1[3]) - bbox1[1] if int(x2 - x1) > 0 and int(y2 - y1) > 0: # show image if it in the visible area x = min(int(x2 / self.imscale), self.width) # sometimes it is larger on 1 pixel... y = min(int(y2 / self.imscale), self.height) # ...and sometimes not image = self.image.crop((int(x1 / self.imscale), int(y1 / self.imscale), x, y)) imagetk = ImageTk.PhotoImage(image.resize((int(x2 - x1), int(y2 - y1)))) imageid = self.canvas.create_image(max(bbox2[0], bbox1[0]), max(bbox2[1], bbox1[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 path = 'doge.jpg' # place path to your image here root = tk.Tk() app = Zoom_Advanced(root, path=path) root.mainloop()
EDIT:
I've created even more advanced zoom. There is "image pyramid" for smooth zooming of large images and even ability to open and zoom huge TIFF files up to several gigabytes.
Version 3.0 is tested on Windows 7 64-bit and Python 3.7.
# -*- coding: utf-8 -*- # Advanced zoom for images of various types from small to huge up to several GB import math import warnings import tkinter as tk from tkinter import ttk from PIL import Image, ImageTk 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) def pack(self, **kw): raise tk.TclError('Cannot use pack with the widget ' + self.__class__.__name__) 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, placeholder, path): """ Initialize the ImageFrame """ self.imscale = 1.0 # scale for the canvas image zoom, public for outer classes self.__delta = 1.3 # zoom magnitude self.__filter = Image.ANTIALIAS # could be: NEAREST, BILINEAR, BICUBIC and ANTIALIAS self.__previous_state = 0 # previous state of the keyboard self.path = path # path to the image, should be public for outer classes # Create ImageFrame in placeholder widget self.__imframe = ttk.Frame(placeholder) # placeholder of the ImageFrame object # Vertical and horizontal scrollbars for canvas hbar = AutoScrollbar(self.__imframe, orient='horizontal') vbar = AutoScrollbar(self.__imframe, orient='vertical') hbar.grid(row=1, column=0, sticky='we') vbar.grid(row=0, column=1, sticky='ns') # Create canvas and bind it with scrollbars. Public for outer classes self.canvas = tk.Canvas(self.__imframe, highlightthickness=0, xscrollcommand=hbar.set, yscrollcommand=vbar.set) self.canvas.grid(row=0, column=0, sticky='nswe') self.canvas.update() # wait till canvas is created hbar.configure(command=self.__scroll_x) # bind scrollbars to the canvas vbar.configure(command=self.__scroll_y) # Bind events to the Canvas self.canvas.bind('<Configure>', lambda event: self.__show_image()) # canvas is resized self.canvas.bind('<ButtonPress-1>', self.__move_from) # remember canvas position self.canvas.bind('<B1-Motion>', self.__move_to) # move canvas to the new position self.canvas.bind('<MouseWheel>', self.__wheel) # zoom for Windows and MacOS, but not Linux self.canvas.bind('<Button-5>', self.__wheel) # zoom for Linux, wheel scroll down self.canvas.bind('<Button-4>', 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('<Key>', lambda event: self.canvas.after_idle(self.__keystroke, event)) # Decide if this image huge or not self.__huge = False # huge or not self.__huge_size = 14000 # define size of the huge image self.__band_width = 1024 # width of the tile band Image.MAX_IMAGE_PIXELS = 1000000000 # suppress DecompressionBombError for the big image with warnings.catch_warnings(): # suppress DecompressionBombWarning warnings.simplefilter('ignore') self.__image = Image.open(self.path) # open image, but down't load it self.imwidth, self.imheight = self.__image.size # public for outer classes if self.imwidth * self.imheight > self.__huge_size * self.__huge_size and \ self.__image.tile[0][0] == 'raw': # only raw images could be tiled self.__huge = True # image is huge self.__offset = self.__image.tile[0][2] # initial tile offset self.__tile = [self.__image.tile[0][0], # it have to be 'raw' [0, 0, self.imwidth, 0], # tile extent (a rectangle) self.__offset, self.__image.tile[0][3]] # list of arguments to the decoder self.__min_side = min(self.imwidth, self.imheight) # get the smaller image side # Create image pyramid self.__pyramid = [self.smaller()] if self.__huge else [Image.open(self.path)] # Set ratio coefficient for image pyramid self.__ratio = max(self.imwidth, self.imheight) / self.__huge_size if self.__huge else 1.0 self.__curr_img = 0 # current image from the pyramid self.__scale = self.imscale * self.__ratio # image pyramide scale self.__reduction = 2 # reduction degree of image pyramid w, h = self.__pyramid[-1].size while w > 512 and h > 512: # top pyramid image is around 512 pixels in size w /= self.__reduction # divide on reduction degree h /= self.__reduction # divide on reduction degree self.__pyramid.append(self.__pyramid[-1].resize((int(w), int(h)), self.__filter)) # Put image into container rectangle and use it to set proper coordinates to the image self.container = self.canvas.create_rectangle((0, 0, self.imwidth, self.imheight), width=0) self.__show_image() # show image on the canvas self.canvas.focus_set() # set focus on the canvas def smaller(self): """ Resize image proportionally and return smaller image """ w1, h1 = float(self.imwidth), float(self.imheight) w2, h2 = float(self.__huge_size), float(self.__huge_size) aspect_ratio1 = w1 / h1 aspect_ratio2 = w2 / h2 # it equals to 1.0 if aspect_ratio1 == aspect_ratio2: image = Image.new('RGB', (int(w2), int(h2))) k = h2 / h1 # compression ratio w = int(w2) # band length elif aspect_ratio1 > aspect_ratio2: image = Image.new('RGB', (int(w2), int(w2 / aspect_ratio1))) k = h2 / w1 # compression ratio w = int(w2) # band length else: # aspect_ratio1 < aspect_ration2 image = Image.new('RGB', (int(h2 * aspect_ratio1), int(h2))) k = h2 / h1 # compression ratio w = int(h2 * aspect_ratio1) # band length i, j, n = 0, 1, round(0.5 + self.imheight / self.__band_width) while i < self.imheight: print('\rOpening image: {j} from {n}'.format(j=j, n=n), end='') band = min(self.__band_width, self.imheight - i) # width of the tile band self.__tile[1][3] = band # set band width self.__tile[2] = self.__offset + self.imwidth * i * 3 # tile offset (3 bytes per pixel) self.__image.close() self.__image = Image.open(self.path) # reopen / reset image self.__image.size = (self.imwidth, band) # set size of the tile band self.__image.tile = [self.__tile] # set tile cropped = self.__image.crop((0, 0, self.imwidth, band)) # crop tile band image.paste(cropped.resize((w, int(band * k)+1), self.__filter), (0, int(i * k))) i += band j += 1 print('\r' + 30*' ' + '\r', end='') # hide printed string return image def redraw_figures(self): """ Dummy function to redraw figures in the children classes """ pass 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 pack(self, **kw): """ Exception: cannot use pack with this widget """ raise Exception('Cannot use pack with the widget ' + self.__class__.__name__) def place(self, **kw): """ Exception: cannot use place with this widget """ raise Exception('Cannot use place with the widget ' + self.__class__.__name__) # 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 __show_image(self): """ Show image on the Canvas. Implements correct image zoom almost like in Google Maps """ 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 if self.__huge and self.__curr_img < 0: # show huge image h = int((y2 - y1) / self.imscale) # height of the tile band self.__tile[1][3] = h # set the tile band height self.__tile[2] = self.__offset + self.imwidth * int(y1 / self.imscale) * 3 self.__image.close() self.__image = Image.open(self.path) # reopen / reset image self.__image.size = (self.imwidth, h) # set size of the tile band self.__image.tile = [self.__tile] image = self.__image.crop((int(x1 / self.imscale), 0, int(x2 / self.imscale), h)) else: # show normal image image = self.__pyramid[max(0, self.__curr_img)].crop( # crop current img from pyramid (int(x1 / self.__scale), int(y1 / self.__scale), int(x2 / self.__scale), int(y2 / self.__scale))) # imagetk = ImageTk.PhotoImage(image.resize((int(x2 - x1), int(y2 - y1)), self.__filter)) 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 __move_from(self, event): """ Remember previous coordinates for scrolling with the mouse """ self.canvas.scan_mark(event.x, event.y) def __move_to(self, event): """ Drag (move) canvas to the new position """ 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 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: # scroll down, smaller if round(self.__min_side * self.imscale) < 30: return # image is less than 30 pixels self.imscale /= self.__delta scale /= self.__delta if event.num == 4 or event.delta == 120: # scroll up, bigger i = min(self.canvas.winfo_width(), self.canvas.winfo_height()) >> 1 if i < self.imscale: return # 1 pixel is bigger than the visible area self.imscale *= self.__delta scale *= self.__delta # Take appropriate image from the pyramid k = self.imscale * self.__ratio # temporary coefficient self.__curr_img = min((-1) * int(math.log(k, self.__reduction)), len(self.__pyramid) - 1) self.__scale = k * math.pow(self.__reduction, max(0, self.__curr_img)) # self.canvas.scale('all', x, y, scale, scale) # rescale all objects # Redraw some figures before showing image on the screen self.redraw_figures() # method for child classes self.__show_image() def __keystroke(self, event): """ Scrolling with the keyboard. Independent from the language of the keyboard, CapsLock, <Ctrl>+<key>, 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 if event.keycode in [68, 39, 102]: # scroll right: keys 'D', 'Right' or 'Numpad-6' self.__scroll_x('scroll', 1, 'unit', event=event) elif event.keycode in [65, 37, 100]: # scroll left: keys 'A', 'Left' or 'Numpad-4' self.__scroll_x('scroll', -1, 'unit', event=event) elif event.keycode in [87, 38, 104]: # scroll up: keys 'W', 'Up' or 'Numpad-8' self.__scroll_y('scroll', -1, 'unit', event=event) elif event.keycode in [83, 40, 98]: # scroll down: keys 'S', 'Down' or 'Numpad-2' self.__scroll_y('scroll', 1, 'unit', event=event) def crop(self, bbox): """ Crop rectangle from the image and return it """ if self.__huge: # image is huge and not totally in RAM band = bbox[3] - bbox[1] # width of the tile band self.__tile[1][3] = band # set the tile height self.__tile[2] = self.__offset + self.imwidth * bbox[1] * 3 # set offset of the band self.__image.close() self.__image = Image.open(self.path) # reopen / reset image self.__image.size = (self.imwidth, band) # set size of the tile band self.__image.tile = [self.__tile] return self.__image.crop((bbox[0], 0, bbox[2], band)) else: # image is totally in RAM return self.__pyramid[0].crop(bbox) def destroy(self): """ ImageFrame destructor """ self.__image.close() map(lambda i: i.close, self.__pyramid) # close all pyramid images del self.__pyramid[:] # delete pyramid list del self.__pyramid # delete pyramid variable self.canvas.destroy() self.__imframe.destroy() class MainWindow(ttk.Frame): """ Main window class """ def __init__(self, mainframe, path): """ Initialize the main Frame """ ttk.Frame.__init__(self, master=mainframe) self.master.title('Advanced Zoom v3.0') self.master.geometry('800x600') # size of the main window self.master.rowconfigure(0, weight=1) # make the CanvasImage widget expandable self.master.columnconfigure(0, weight=1) canvas = CanvasImage(self.master, path) # create widget canvas.grid(row=0, column=0) # show widget filename = './data/img_plg5.png' # place path to your image here #filename = 'd:/Data/yandex_z18_1-1.tif' # huge TIFF file 1.4 GB #filename = 'd:/Data/The_Garden_of_Earthly_Delights_by_Bosch_High_Resolution.jpg' #filename = 'd:/Data/The_Garden_of_Earthly_Delights_by_Bosch_High_Resolution.tif' #filename = 'd:/Data/heic1502a.tif' #filename = 'd:/Data/land_shallow_topo_east.tif' #filename = 'd:/Data/X1D5_B0002594.3FR' app = MainWindow(tk.Tk(), path=filename) app.mainloop()
P.S. Here is the GitHub application using advanced zoom for manual image annotation with polygons.
这篇关于Tkinter画布缩放+移动/平移的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!