QGraphicsPixmapItem zooming in/out - python

My application is for drawing shapes (polygons, points ..etc), I used QGrapichView and QGrapichScene and I added two different pixmap items (one for drawing, and the other one is 'legend' that will show the user how much the distance is), So far so good.
I implemented the zoom functionality and it is working fun. Still, my problem is: that when I zoom in/out the whole scene (the canvas QGraphicsPixmapItem and the legend QGraphicsPixmapItem) is affected and that is expected because I am re-scaling the QGrapichView while zooming (using the mouse wheel).
What I want is: I need only the canvas item to be zoomed in/out not the whole scene so that the legend will always be visible to the user.
Here is a snippet of the code that am using in zooming from this answer:
class PixmapScene(QGraphicsScene):
pass
class Canvas(QGraphicsView):
def __init__(self, scene):
super().__init__(scene)
self.scene = scene
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
background_color = QColor("#443b36")
self.pixmap_item: QGraphicsItem = self.scene.addPixmap(QPixmap(780, 580))
self.pixmap_item.setTransformationMode(Qt.FastTransformation)
self.pixmap_item.pixmap().fill(background_color)
self.legend = QPixmap(780, 580)
self.legend.fill(QColor("#00ffffff"))
p = QtGui.QPainter(self.legend)
p.setPen(QPen(QColor("#0000FF"), 4))
p.drawLine(35 * 5.1, self.legend.height() - 60, 35 * 8.9, self.legend.height() - 60)
p.setPen(QColor("#c9c9c9"))
p.setFont(QFont('Century Gothic', 14))
p.drawText(35 * 5.5, self.legend.height() - 35, f'this text is from the other pixmap (legend)')
p.end()
self.scene.addPixmap(self.legend)
self.zoom_times = 0
def wheelEvent(self, event):
zoom_in_factor = 1.25
zoom_out_factor = 1 / zoom_in_factor
# Save the scene pos
old_pos = self.mapToScene(event.pos())
# Zoom
if event.angleDelta().y() > 0:
if self.zoom_times == 6:
return
zoom_factor = zoom_in_factor
self.zoom_times += 1
else:
if self.zoom_times == 0:
return
zoom_factor = zoom_out_factor
self.zoom_times -= 1
# here we are scaling the whole scene, what I want is zooming with keeping legend as it is
self.scale(zoom_factor, zoom_factor)
# Get the new position
new_pos = self.mapToScene(event.pos())
# Move scene to old position
delta = new_pos - old_pos
self.translate(delta.x(), delta.y())

Related

Rotated QGraphicsRectItem moves randomly when dragged using mouse

I have a movable QGraphicsRectItem which is rotated to 90 degrees and set to a scene. When I drag the item, it moves randomly and eventually disappear.
However, when I set the rotation to 0, the item moves flawlessly.
Here is my minimal reproducible example.
class main_window(QWidget):
def __init__(self):
super().__init__()
self.rect = Rectangle(100, 100, 100, 100)
self.rect.setRotation(90)
self.view = QGraphicsView(self)
self.scene = QGraphicsScene(self.view)
self.scene.addItem(self.rect)
self.view.setSceneRect(0, 0, 500,500)
self.view.setScene(self.scene)
self.slider = QSlider(QtCore.Qt.Horizontal)
self.slider.setMinimum(0)
self.slider.setMaximum(90)
vbox = QVBoxLayout(self)
vbox.addWidget(self.view)
vbox.addWidget(self.slider)
self.setLayout(vbox)
self.slider.valueChanged.connect(self.rotate)
def rotate(self, value):
self.angle = int(value)
self.rect.setRotation(self.angle)
class Rectangle(QGraphicsRectItem):
def __init__(self, *args):
super().__init__(*args)
self.setFlag(QGraphicsItem.ItemIsMovable, True)
self.setFlag(QGraphicsItem.ItemIsSelectable, True)
self.setFlag(QGraphicsItem.ItemSendsScenePositionChanges, True)
self.setPen(QPen(QBrush(QtGui.QColor('red')), 5))
self.selected_edge = None
self.first_pos = None
self.click_rect = None
def mousePressEvent(self, event):
self.first_pos = event.pos()
self.rect_shape = self.rect()
self.click_rect = self.rect_shape
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
# Calculate how much the mouse has moved since the click.
self.pos = event.pos()
x_diff = self.pos.x() - self.first_pos.x()
y_diff = self.pos.y() - self.first_pos.y()
# Start with the rectangle as it was when clicked.
self.rect_shape = QtCore.QRectF(self.click_rect)
self.rect_shape.translate(x_diff, y_diff)
self.setRect(self.rect_shape)
self.setTransformOriginPoint(self.rect_shape.center())
(I included a slider at the bottom of the main window to conveniently rotate the item)
Why does this happen?
The issue is caused by various aspects:
setting a QRect at given coordinates while keeping the item at the same default position (0, 0);
changing the rectangle as consequence of a mouse move event;
changing the transformation origin point after that;
the mapping of the mouse coordinates between integer based point (on the screen) and floating (on the scene);
the transformation (rotation);
implementing the item movement without considering the above (while QGraphicsItem already provides it with the ItemIsMovable flag);
Note that while rotation might seem a simple operation, it is achieved by using a combination of two transformations: shearing and scaling; this means that the transformation applies very complex computations that depend on the floating point precision.
This becomes an issue when dealing with integer to floating conversion: the same mouse (integer based) coordinate can be mapped at a very different point depending on the transformation, and the "higher" the transformation is applied, the bigger the difference can be. As a result, the mapped mouse position can be very different, the rectangle is translated to a "wrong" point, and the transformation origin point moves the rectangle "away" by an increasing ratio.
The solution is to completely change the way the rectangle is positioned and actually simplify the reference: the rectangle is always centered at the item position, so that we can keep the transformation origin point at the default (0, 0 in item coordinates).
The only inconvenience with this approach is that the item's pos() will not be on its top left corner anymore, but that is not a real issue: when the item is rotated, its top left corner would not be at that position anyway.
If you need to know the actual position of the item, you can then translate the rectangle based on the item scene position.
If you want to position the rectangle based on its top left corner, you have to map the position from the scene and compute the delta of the reference point (the actual top left corner).
I took the liberty of taking your previous question, which implemented the resizing, and improving it also to better show how the solution works.
class Selection(QtWidgets.QGraphicsRectItem):
Left, Top, Right, Bottom = 1, 2, 4, 8
def __init__(self, *args):
rect = QtCore.QRectF(*args)
pos = rect.center()
# move the center of the rectangle to 0, 0
rect.translate(-rect.center())
super().__init__(rect)
self.setPos(pos)
self.setPen(QtGui.QPen(QtCore.Qt.red, 5))
self.setFlags(
self.ItemIsMovable |
self.ItemIsSelectable |
self.ItemSendsGeometryChanges
)
def mapRect(self):
return QtCore.QRectF(
self.mapToScene(self.rect().topLeft()),
self.rect().size()
)
def setRectPosition(self, pos):
localPos = self.mapFromScene(pos)
delta = self.rect().topLeft() - localPos
self.setPos(self.pos() + delta)
def itemChange(self, change, value):
if change in (self.ItemPositionHasChanged, self.ItemRotationHasChanged):
print(self.mapRect())
return super().itemChange(change, value)
def mousePressEvent(self, event):
super().mousePressEvent(event)
pos = event.pos()
rect = self.rect()
margin = self.pen().width() / 2
self.anchor = 0
if pos.x() <= rect.x() + margin:
self.anchor |= self.Left
elif pos.x() >= rect.right() - margin:
self.anchor |= self.Right
if pos.y() <= rect.y() + margin:
self.anchor |= self.Top
elif pos.y() >= rect.bottom() - margin:
self.anchor |= self.Bottom
if self.anchor:
self.clickAngle = QtCore.QLineF(QtCore.QPointF(), pos).angle()
else:
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
if not self.anchor:
super().mouseMoveEvent(event)
return
rect = self.rect()
pos = event.pos()
if self.anchor == self.Left:
rect.setLeft(pos.x())
elif self.anchor == self.Right:
rect.setRight(pos.x())
elif self.anchor == self.Top:
rect.setTop(pos.y())
elif self.anchor == self.Bottom:
rect.setBottom(pos.y())
else:
# clicked on a corner, let's rotate
angle = QtCore.QLineF(QtCore.QPointF(), pos).angle()
rotation = max(0, min(90, self.rotation() + self.clickAngle - angle))
self.setRotation(rotation)
return
pos = self.mapToScene(rect.center())
self.setPos(pos)
rect.moveCenter(QtCore.QPointF())
self.setRect(rect)

get real size of QPixmap in Qlabel

Is there some simple way in PyQt5 to get real dimensions of the pixmap displayed in QLabel? I am trying to select part of the image with rubber band. But I can't find a way to limit the rubberband only to pixmap. The QLabel.pixmap().rect() returns dimensions of the whole QLabel not only the pixmap. The problem arises when the pixmap is scaled and there are stripes on the sides of the picture.
The Example image
Example image 2
I posted are quite self explanatory. I don't want the rubberband to be able to move out of the picture to the white stripes.
class ResizableRubberBand(QWidget):
def __init__(self, parent=None):
super(ResizableRubberBand, self).__init__(parent)
self.aspect_ratio = None
self.setWindowFlags(Qt.SubWindow)
self.layout = QHBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
self.grip1 = QSizeGrip(self)
self.grip2 = QSizeGrip(self)
self.layout.addWidget(self.grip1, 0, Qt.AlignLeft | Qt.AlignTop)
self.layout.addWidget(self.grip2, 0, Qt.AlignRight | Qt.AlignBottom)
self.rubberband = QRubberBand(QRubberBand.Rectangle, self)
self.rubberband.setStyle(QStyleFactory.create("Fusion"))
self.rubberband.move(0, 0)
self.rubberband.show()
self.show()
class ResizablePixmap(QLabel):
def __init__(self, bytes_image):
QLabel.__init__(self)
self.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored)
self.setAlignment(Qt.AlignVCenter | Qt.AlignHCenter)
self.setStyleSheet("background-color:#ffffff;")
self.update_pixmap(bytes_image)
def resizeEvent(self, event):
if event:
x = event.size().width()
y = event.size().height()
else:
x = self.width()
y = self.height()
self.current_pixmap = self._bytes2pixmap(self.bytes_image_edit)
self.setPixmap(self.current_pixmap.scaled(x, y, Qt.KeepAspectRatio))
self.resize(x, y)
def update_pixmap(self, bytes_image):
self.bytes_image_edit = bytes_image
self.current_pixmap = self._bytes2pixmap(bytes_image)
self.setPixmap(self.current_pixmap)
self.resizeEvent(None)
#staticmethod
def _bytes2pixmap(raw_image):
image = QImage()
image.loadFromData(raw_image)
return QPixmap(image)
#staticmethod
def _pixmap2bytes(pixmap):
byte_array = QByteArray()
buffer = QBuffer(byte_array)
buffer.open(QIODevice.WriteOnly)
pixmap.save(buffer, 'PNG')
return byte_array.data()
#property
def image_dims(self):
return self.width(), self.height()
def force_resize(self, qsize):
self.resizeEvent(QResizeEvent(qsize, qsize))
class SelectablePixmap(ResizablePixmap):
def __init__(self, bytes_image):
super().__init__(bytes_image)
self.currentQRubberBand = None
self.move_rubber_band = False
self.rubber_band_offset = None
def cancel_selection(self):
self.currentQRubberBand.hide()
self.currentQRubberBand.deleteLater()
self.currentQRubberBand = None
self.selectionActive.emit(False)
def mousePressEvent(self, eventQMouseEvent):
if not self.currentQRubberBand:
self.currentQRubberBand = ResizableRubberBand(self)
self.selectionActive.emit(True)
if self.currentQRubberBand.geometry().contains(eventQMouseEvent.pos()):
self.move_rubber_band = True
self.rubber_band_offset = (eventQMouseEvent.pos() -
self.currentQRubberBand.pos())
else:
self.originQPoint = eventQMouseEvent.pos()
if self.pixmap().rect().contains(self.originQPoint):
self.currentQRubberBand.setGeometry(QRect(self.originQPoint,
QSize()))
self.currentQRubberBand.show()
def mouseMoveEvent(self, eventQMouseEvent):
if self.move_rubber_band:
pos = eventQMouseEvent.pos() - self.rubber_band_offset
if self.pixmap().rect().contains(pos):
self.currentQRubberBand.move(pos)
else:
rect = QRect(self.originQPoint, eventQMouseEvent.pos())
self.currentQRubberBand.setGeometry(rect.normalized())
def mouseReleaseEvent(self, eventQMouseEvent):
if self.move_rubber_band:
self.move_rubber_band = False
The "easy" answer to your question is that you can get the actual geometry of the QPixmap by moving its QRect. Since you're using center alignment, that's very simple:
pixmap_rect = self.pixmap.rect()
pixmap_rect.moveCenter(self.rect().center())
Unfortunately you can't just use that rectangle with your implementation, mostly because you are not really using a QRubberBand.
The concept of a child rubberband, using size grips for resizing, is clever, but has a lot of limitations.
While QSizeGrips make resizing easier, their behavior can't be easily "restricted": you'll probably end up trying to reimplement resize and resizeEvent (risking recursions), maybe with some tricky and convoluted mouse event checking. Also, you'll never be able to resize that "virtual" rubberband to a size smaller to the sum of the QSizeGrips' sizes, nor to a "negative" selection.
Also, in your code you never resize the actual QRubberBand geometry (but that can be done within the ResizableRubberBand.resizeEvent()).
Finally, even if you haven't implemented the selection resizing after an image resizing, you would have a lot of issues if you did (mostly because of the aforementioned minimum size restrainings).
I think that a better solution is to use a simple QRubberBand and implement its interaction directly from the widget that uses it. This lets you have finer control over it, also allowing complete resize features (not only top left and bottom right corners).
I slightly modified your base class code, as you should avoid any resizing within a resizeEvent() (even if it didn't do anything in your case, since the size argument of resize() was the same) and did unnecessary calls to _bytes2pixmap.
class ResizablePixmap(QLabel):
def __init__(self, bytes_image):
QLabel.__init__(self)
self.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored)
self.setAlignment(Qt.AlignCenter)
self.setStyleSheet("background-color: #ffffff;")
self.update_pixmap(bytes_image)
def update_pixmap(self, bytes_image):
self.bytes_image_edit = bytes_image
self.current_pixmap = self._bytes2pixmap(bytes_image)
def scale(self, fromResize=False):
# use a single central method for scaling; there's no need to call it upon
# creation and also resize() won't work anyway in a layout
self.setPixmap(self.current_pixmap.scaled(self.width(), self.height(),
Qt.KeepAspectRatio, Qt.SmoothTransformation))
def resizeEvent(self, event):
super(ResizablePixmap, self).resizeEvent(event)
self.scale(True)
#staticmethod
def _bytes2pixmap(raw_image):
image = QImage()
image.loadFromData(raw_image)
return QPixmap(image)
class SelectablePixmap(ResizablePixmap):
selectionActive = pyqtSignal(bool)
def __init__(self, bytes_image):
super().__init__(bytes_image)
# activate mouse tracking to change cursor on rubberband hover
self.setMouseTracking(True)
self.currentQRubberBand = None
self.rubber_band_offset = None
self.moveDirection = 0
def create_selection(self, pos):
if self.currentQRubberBand:
self.cancel_selection()
self.currentQRubberBand = QRubberBand(QRubberBand.Rectangle, self)
self.currentQRubberBand.setStyle(QStyleFactory.create("Fusion"))
self.currentQRubberBand.setGeometry(pos.x(), pos.y(), 1, 1)
self.currentQRubberBand.show()
self.originQPoint = pos
self.currentQRubberBand.installEventFilter(self)
def cancel_selection(self):
self.currentQRubberBand.hide()
self.currentQRubberBand.deleteLater()
self.currentQRubberBand = None
self.originQPoint = None
self.selectionActive.emit(False)
def scale(self, fromResize=False):
if fromResize and self.currentQRubberBand:
# keep data for rubber resizing, before scaling
oldPixmapRect = self.pixmap().rect()
oldOrigin = self.currentQRubberBand.pos() - self.pixmapRect.topLeft()
super(SelectablePixmap, self).scale()
# assuming that you always align the image in the center, get the current
# pixmap rect and move the rectangle center to the current geometry
self.pixmapRect = self.pixmap().rect()
self.pixmapRect.moveCenter(self.rect().center())
if fromResize and self.currentQRubberBand:
# find the new size ratio based on the previous
xRatio = self.pixmapRect.width() / oldPixmapRect.width()
yRatio = self.pixmapRect.height() / oldPixmapRect.height()
# create a new geometry using 0-rounding for improved accuracy
self.currentQRubberBand.setGeometry(
round(oldOrigin.x() * xRatio, 0) + self.pixmapRect.x(),
round(oldOrigin.y() * yRatio + self.pixmapRect.y(), 0),
round(self.currentQRubberBand.width() * xRatio, 0),
round(self.currentQRubberBand.height() * yRatio, 0))
def updateMargins(self):
# whenever the rubber rectangle geometry changes, create virtual
# rectangles for corners and sides to ease up mouse event checking
rect = self.currentQRubberBand.geometry()
self.rubberTopLeft = QRect(rect.topLeft(), QSize(8, 8))
self.rubberTopRight = QRect(rect.topRight(), QSize(-8, 8)).normalized()
self.rubberBottomRight = QRect(rect.bottomRight(), QSize(-8, -8)).normalized()
self.rubberBottomLeft = QRect(rect.bottomLeft(), QSize(8, -8)).normalized()
self.rubberLeft = QRect(self.rubberTopLeft.bottomLeft(), self.rubberBottomLeft.topRight())
self.rubberTop = QRect(self.rubberTopLeft.topRight(), self.rubberTopRight.bottomLeft())
self.rubberRight = QRect(self.rubberTopRight.bottomLeft(), self.rubberBottomRight.topRight())
self.rubberBottom = QRect(self.rubberBottomLeft.topRight(), self.rubberBottomRight.bottomLeft())
self.rubberInnerRect = QRect(self.rubberTop.bottomLeft(), self.rubberBottom.topRight())
def eventFilter(self, source, event):
if event.type() in (QEvent.Resize, QEvent.Move):
self.updateMargins()
return super(SelectablePixmap, self).eventFilter(source, event)
def mousePressEvent(self, event):
pos = event.pos()
if not self.currentQRubberBand or not pos in self.currentQRubberBand.geometry():
if pos not in self.pixmapRect:
self.originQPoint = None
return
self.create_selection(pos)
elif pos in self.rubberTopLeft:
self.originQPoint = self.currentQRubberBand.geometry().bottomRight()
elif pos in self.rubberTopRight:
self.originQPoint = self.currentQRubberBand.geometry().bottomLeft()
elif pos in self.rubberBottomRight:
self.originQPoint = self.currentQRubberBand.geometry().topLeft()
elif pos in self.rubberBottomLeft:
self.originQPoint = self.currentQRubberBand.geometry().topRight()
elif pos in self.rubberTop:
self.originQPoint = self.currentQRubberBand.geometry().bottomLeft()
self.moveDirection = Qt.Vertical
elif pos in self.rubberBottom:
self.originQPoint = self.currentQRubberBand.geometry().topLeft()
self.moveDirection = Qt.Vertical
elif pos in self.rubberLeft:
self.originQPoint = self.currentQRubberBand.geometry().topRight()
self.moveDirection = Qt.Horizontal
elif pos in self.rubberRight:
self.originQPoint = self.currentQRubberBand.geometry().topLeft()
self.moveDirection = Qt.Horizontal
else:
self.rubber_band_offset = pos - self.currentQRubberBand.pos()
def mouseMoveEvent(self, event):
pos = event.pos()
if event.buttons() == Qt.NoButton and self.currentQRubberBand:
if pos in self.rubberTopLeft or pos in self.rubberBottomRight:
self.setCursor(Qt.SizeFDiagCursor)
elif pos in self.rubberTopRight or pos in self.rubberBottomLeft:
self.setCursor(Qt.SizeBDiagCursor)
elif pos in self.rubberLeft or pos in self.rubberRight:
self.setCursor(Qt.SizeHorCursor)
elif pos in self.rubberTop or pos in self.rubberBottom:
self.setCursor(Qt.SizeVerCursor)
elif pos in self.rubberInnerRect:
self.setCursor(Qt.SizeAllCursor)
else:
self.unsetCursor()
elif event.buttons():
if self.rubber_band_offset:
target = pos - self.rubber_band_offset
rect = QRect(target, self.currentQRubberBand.size())
# limit positioning of the selection to the image rectangle
if rect.x() < self.pixmapRect.x():
rect.moveLeft(self.pixmapRect.x())
elif rect.right() > self.pixmapRect.right():
rect.moveRight(self.pixmapRect.right())
if rect.y() < self.pixmapRect.y():
rect.moveTop(self.pixmapRect.y())
elif rect.bottom() > self.pixmapRect.bottom():
rect.moveBottom(self.pixmapRect.bottom())
self.currentQRubberBand.setGeometry(rect)
elif self.originQPoint:
if self.moveDirection == Qt.Vertical:
# keep the X fixed to the current right, so that only the
# vertical position is changed
pos.setX(self.currentQRubberBand.geometry().right())
else:
# limit the X to the pixmapRect extent
if pos.x() < self.pixmapRect.x():
pos.setX(self.pixmapRect.x())
elif pos.x() > self.pixmapRect.right():
pos.setX(self.pixmapRect.right())
if self.moveDirection == Qt.Horizontal:
# same as before, but for the Y position
pos.setY(self.currentQRubberBand.geometry().bottom())
else:
# limit the Y to the pixmapRect extent
if pos.y() < self.pixmapRect.y():
pos.setY(self.pixmapRect.y())
elif pos.y() > self.pixmapRect.bottom():
pos.setY(self.pixmapRect.bottom())
rect = QRect(self.originQPoint, pos)
self.currentQRubberBand.setGeometry(rect.normalized())
def mouseReleaseEvent(self, event):
self.rubber_band_offset = None
self.originQPoint = None
self.moveDirection = 0
You could store width and height of the image (before you create the pixmap from bytes) into global variable and then use getter to access it from outside of class.

tkinter - problem with a coloring a rectangle in a grid made out of rectangles

I am trying to write a pathfinding algorithm in python. The user is supposed to select a starting point by hovering the mouse of a field and pressing s. The field should now change the color.
However, I can't figure out what is wrong with my code. I am only able to color to color the fields from top left corner to the bottom right corner. In the code, Im printing out the objectID in console, which shows that there is maybe something wrong with the way of how I created the rectangles.
I'm creating the rectangles in the draw_grid method in the Window class and coloring the fields in the select_start_node method.
import tkinter as tk
class Window:
def __init__(self):
self.height = 600
self.width = 600
self.grid_list = {x for x in range(0, 600)}
self.grid = []
self.grid_dict = {}
self.root = tk.Tk()
self.root.geometry("600x600")
self.root.resizable(False, False)
self.canvas = tk.Canvas(self.root, width=self.width,
height=self.height, background="white")
self.canvas.bind("s", self.select_start_node)
self.canvas.bind("<1>", lambda event:
self.canvas.focus_set())
def draw_grid(self):
print(self.grid)
for x in self.grid_list:
if x % 30 == 0:
self.grid.append(x)
else:
pass
print(self.grid)
for x in self.grid:
for y in self.grid:
print(x, y+30)
rec = self.canvas.create_rectangle(x, x, y+30, y+30)
self.canvas.pack()
def select_start_node(self, event):
print(event.x, event.y)
x = self.canvas.canvasx(event.x)
y = self.canvas.canvasy(event.y)
item = self.canvas.find_closest(x, y)
p = self.canvas.coords(item)
print(item)
print(p)
self.canvas.create_rectangle(p[0], p[0], p[0]+30, p[0]+30, fill="red")
def main():
node_list = []
cord_list = []
window = Window()
window.draw_grid()
window.root.mainloop()
if __name__ == "__main__":
main()
I don't know the entire design of you game, but suggest that you do things differently with respect to the grid of rectangles. In the code below self.grid is a 2-dimensional list-of-lists and each entry is a Canvas rectangle object. This make selecting and changing one of them relatively each because canvas.find_closest(x, y) will give you the object id of the associated rectangle object directly, which makes changing its fill color trivial.
Because of that, I also changed it so you can just click on one of the rectangles to change it instead of moving the mouse cursor and then pressing a key.
Also note that I also got rid of most those hardcoded numerical constants you were using all over the place, which makes the code more flexible in case you decide to change one of them at a later time.
import tkinter as tk
class Window:
def __init__(self):
self.cell_size = 30
self.height = 600
self.width = 600
self.hz_cells = self.width // self.cell_size # Number of horizontal cells.
self.vt_cells = self.height // self.cell_size # Number of vertical cells.
# Preallocate 2D grid (list-of-lists).
self.grid = [[None for _ in range(self.hz_cells)]
for _ in range(self.vt_cells)]
self.root = tk.Tk()
self.root.geometry("%sx%s" % (self.width, self.height))
self.root.resizable(False, False)
self.canvas = tk.Canvas(self.root, width=self.width,
height=self.height, background="white")
self.canvas.pack()
self.canvas.bind("<1>", self.select_start_node)
# You can still do it this way if you want.
# self.canvas.bind("s", self.select_start_node)
# self.canvas.bind("<1>", lambda event: self.canvas.focus_set())
def draw_grid(self):
""" Fill Canvas with a grid of white rectangles. """
for i in range(self.hz_cells):
x = i * self.cell_size
for j in range(self.vt_cells):
y = j * self.cell_size
self.grid[i][j] = self.canvas.create_rectangle(
x, y, x+self.cell_size, y+self.cell_size, fill="white")
def select_start_node(self, event):
""" Change the color of the rectangle closest to x, y of event. """
x = self.canvas.canvasx(event.x)
y = self.canvas.canvasy(event.y)
selected_rect = self.canvas.find_closest(x, y)
if selected_rect:
self.canvas.itemconfigure(selected_rect, fill="red") # Change color.
def main():
node_list = []
cord_list = []
window = Window()
window.draw_grid()
window.root.mainloop()
if __name__ == "__main__":
main()

Tkinter canvas zoom + move/pan

Tkinter's canvas widget has built-in features to:
move/pan the canvas (for example with Click + Drag) with canvas.scan_mark and canvas.scan_dragto, see this question
zoom 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.
(The question TITLE doesn't indicate that it's focused on bitmaps. I add an answer here for those who were interested in basic zoom/pan support for canvas, and got here by a search engine)
The fundamental mechanism to support zoom (with wheel) and move/pan (with left-button drag) is as follows:
from tkinter import ALL, EventType
canvas.bind("<MouseWheel>", do_zoom)
canvas.bind('<ButtonPress-1>', lambda event: canvas.scan_mark(event.x, event.y))
canvas.bind("<B1-Motion>", lambda event: canvas.scan_dragto(event.x, event.y, gain=1))
def do_zoom(event):
x = canvas.canvasx(event.x)
y = canvas.canvasy(event.y)
factor = 1.001 ** event.delta
canvas.scale(ALL, x, y, factor, factor)
Simple extension: support zooming of each axis individually, by looking at the state of Ctrl and Shift, as follows:
def do_zoom(event):
x = canvas.canvasx(event.x)
y = canvas.canvasy(event.y)
factor = 1.001 ** event.delta
is_shift = event.state & (1 << 0) != 0
is_ctrl = event.state & (1 << 2) != 0
canvas.scale(ALL, x, y,
factor if not is_shift else 1.0,
factor if not is_ctrl else 1.0)
You might consider using map tiles for this case. The tiles can be specific to the zoom level. After selecting the tiles based on the pan and zoom level you can position them on the canvas with Canvas.create_image.
Assuming you have some tile class with its coordinates and image, you could select for visible tiles based on the pan, zoom and frame size.
for tile in visible_tiles(pan_center, frame_dimensions, zoom_level):
canvas.create_image(tile.x, tile.y, anchor=Tkinter.NW, image=tile.image)
There is a full sample of this in Tile-Based Geospatial Information Systems by John Sample and Elias Ioup in the chapter on Tiled Mapping Clients.

Make widgets/frames inside tkinter canvas resizable

I have a number of text widgets floating on a scrollable canvas widget. I want to allow users to resize them by dragging their edges and/or corners, possibly moving them if they drag the upper left edges or corner. I'm open to making them into frames with text widgets inside them, since I'm likely to do that anyway.
I figure I can handle the events manually if I can just get it to show resize handles. Do I need to catch mouseovers and clicks on a border myself?
Adding a "<Configure>" event binding does nothing, as one would expect. ttk.Sizegrip allegedly only works on top-level windows. There are lots of resources for preventing resizes, but very few for facilitating them, and they all seem to be for top-level windows.
I ended up making a frame around the widget with a fat border, catching mouse events, and handling all the dirty resize logic myself.
I had to record both the initial location of the location of the click and the position of each successive mouse-move, and use them for top/left and bottom/right resizes, respectively.
Edit: Here's a handy (relatively) encapsulated implementation.
from Tkinter import *
class ResizableCanvasFrame(Frame):
'''
Class that handles creating resizable frames on a canvas.
Don't pack it.
Set save_callback to whatever you want to happen when the mouse
lets up on the border. You can catch <Configure> too, but at least
in my case I didn't want to save the new position on every mouse move.
'''
def __init__(self, master, x, y, w, h, *args, **kwargs):
# master should be a Canvas
self.frame_thickness = 5
Frame.__init__(
self,
master,
*args,
borderwidth = self.frame_thickness,
cursor = 'fleur',
**kwargs
)
self.canvas = master
self.resize_state = None
self.bind('<Button-1>', self.mousedown)
self.bind('<B1-Motion>', self.mousemove)
self.bind('<ButtonRelease-1>', self.mouseup)
self.bind('<Destroy>', self.delete_item)
# add self to canvas
self.itemid = self.canvas.create_window(
x,
y,
window=self,
anchor="nw",
width=w,
height=h
)
self.save_callback = None
def canvas_coords(self):
return map(int, self.canvas.coords(self.itemid))
def move(self, dx, dy):
# strictly, this is out of the range of RCF,
# but it helps with the law of demeter
self.canvas.move(self.itemid, dx, dy)
def mousedown(self, event):
window_width = self.winfo_width()
window_height = self.winfo_height()
self.resize_state = {
'start_coords': (event.x, event.y),
'last_coords': (event.x, event.y),
'left_edge': (0 <= event.x < self.frame_thickness),
'right_edge': (window_width - self.frame_thickness <= event.x < window_width),
'top_edge': (0 <= event.y < self.frame_thickness),
'bottom_edge': (window_height - self.frame_thickness <= event.y < window_height),
}
def mousemove(self, event):
if self.resize_state:
resize = self.resize_state # debug var
event_x = event.x
event_y = event.y
# distance of cursor from original position of window
delta = map(int, (event.x - self.resize_state['start_coords'][0],
event.y - self.resize_state['start_coords'][1]))
# load current pos, size
new_x, new_y = self.canvas_coords()
new_width = int(self.canvas.itemcget(self.itemid, 'width'))
new_height = int(self.canvas.itemcget(self.itemid, 'height'))
# handle x resize/move
if self.resize_state['left_edge']:
# must move pos and resize
new_x += delta[0]
new_width -= delta[0]
elif self.resize_state['right_edge']:
new_width += (event.x - self.resize_state['last_coords'][0])
# handle y resize/move
if self.resize_state['top_edge']:
new_y += delta[1]
new_height -= delta[1]
elif self.resize_state['bottom_edge']:
new_height += (event.y - self.resize_state['last_coords'][1])
# save new settings in item, not card yet
self.resize_state['last_coords'] = (event.x, event.y)
self.canvas.coords(self.itemid, new_x, new_y)
self.canvas.itemconfig(self.itemid, width=new_width, height=new_height)
def mouseup(self, event):
if self.resize_state:
self.resize_state = None
if self.save_callback:
self.save_callback()
def delete_item(self, event):
self.canvas.delete(self.itemid)
You could use the PanedWindow widget, or a combination of a few of them, inside your canvas. They are designed to do that. Getting the PanedWindow to stretch like the "sticky" command inside the canvas is an unknown though.
That is what I was looking for when stumbled across this post.

Categories