Python Matplotlib: reduce render time for interactive plot - python

I've got the following code that produces a plot that can interactively be modified. Clicking / holding the left mouse button sets the marker position, Holding the right button and moving the mouse moves the plotted data in direction x and using the mouse wheel zooms in/out. Additionally, resizing the window calls figure.tight_layout() so that the size of the axes is adapted to the window size.
# coding=utf-8
from __future__ import division
from Tkinter import *
import matplotlib
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from matplotlib.figure import Figure
from numpy import arange, sin, pi
matplotlib.use('TkAgg')
class PlotFrame(Frame):
def __init__(self, master, **ops):
Frame.__init__(self, master, **ops)
self.figure = Figure()
self.axes_main = self.figure.add_subplot(111)
for i in range(10):
t = arange(0, 300, 0.01)
s = sin(0.02 * pi * (t + 10 * i))
self.axes_main.plot(t, s)
self.plot = FigureCanvasTkAgg(self.figure, master=self)
self.plot.show()
self.plot.get_tk_widget().pack(fill=BOTH, expand=1)
self.dragging = False
self.dragging_button = None
self.mouse_pos = [0, 0]
self.marker = self.figure.axes[0].plot((0, 0), (-1, 1), 'black', linewidth=3)[0]
self.plot.mpl_connect('button_press_event', self.on_button_press)
self.plot.mpl_connect('button_release_event', self.on_button_release)
self.plot.mpl_connect('motion_notify_event', self.on_mouse_move)
self.plot.mpl_connect('scroll_event', self.on_mouse_scroll)
self.plot.mpl_connect("resize_event", self.on_resize)
def on_resize(self, _):
self.figure.tight_layout()
def axes_size(self):
pos = self.axes_main.get_position()
bbox = self.figure.get_window_extent().transformed(self.figure.dpi_scale_trans.inverted())
width, height = bbox.width * self.figure.dpi, bbox.height * self.figure.dpi
axis_size = [(pos.x1 - pos.x0) * width, (pos.y1 - pos.y0) * height]
return axis_size
def on_button_press(self, event):
# right mouse button clicked
if not self.dragging and event.button in (1, 3):
self.dragging = True
self.dragging_button = event.button
self.mouse_pos = [event.x, event.y]
# left mouse button clicked
if event.button == 1 and event.xdata is not None:
self.move_marker(event.xdata)
def on_button_release(self, event):
if self.dragging and self.dragging_button == event.button:
self.dragging = False
def on_mouse_move(self, event):
if self.dragging and self.dragging_button == 3:
dx = event.x - self.mouse_pos[0]
self.mouse_pos = [event.x, event.y]
x_min, x_max = self.figure.axes[0].get_xlim()
x_range = x_max - x_min
x_factor = x_range / self.axes_size()[0]
self.figure.axes[0].set_xlim([x_min - dx * x_factor, x_max - dx * x_factor])
self.plot.draw()
elif self.dragging and self.dragging_button == 1:
self.move_marker(event.xdata)
def on_mouse_scroll(self, event):
if event.xdata is None:
return
zoom_direction = -1 if event.button == 'up' else 1
zoom_factor = 1 + .4 * zoom_direction
x_min, x_max = self.figure.axes[0].get_xlim()
min = event.xdata + (x_min - event.xdata) * zoom_factor
max = event.xdata + (x_max - event.xdata) * zoom_factor
self.figure.axes[0].set_xlim([min, max])
self.plot.draw()
def move_marker(self, x_position):
y_min, y_max = self.figure.axes[0].get_ylim()
self.marker.set_data((x_position, x_position), (y_min, y_max))
self.plot.draw()
if __name__ == '__main__':
gui = Tk()
vf = PlotFrame(gui)
vf.pack(fill=BOTH, expand=1)
gui.mainloop()
The implementation works fine, but rendering is really slow when displaying a lot of lines. How can I make rendering faster? As you can see in the implementation above, the whole plot is drawn completely every time anything changes which shouldn't be necessary. My thoughts on this:
Resizing the window: draw everything
Zooming: draw everything
Moving the marker: just redraw the marker (one line) instead of drawing everything
Moving the plot in x direction: move the pixels currently displayed in the plot left/right and only draw pixels that are moved into the visible area
Drawing everything when resizing/zooming is fine for me, but I really need faster drawing of the latter two modifications. I already looked into matplotlib's animations, but as far as I understood, they won't help in my case. Any help is greatly appreciated, thanks!

The solution seems to be to cache elements that get redrawn as you said:
One major thing that gets redrawn is the background:
# cache the background
background = fig.canvas.copy_from_bbox(ax.bbox)
After caching restore it using restore region then just re-draw the points/line at every call you need
# restore background
fig.canvas.restore_region(background)
# redraw just the points
ax.draw_artist(points)
# fill in the axes rectangle
fig.canvas.blit(ax.bbox)

To optimize drawing blitting can be used. With it only given artists (those that were changed) will be rendered instead of the whole figure.
Motplotlib uses that technique internally in the animation module. You can use Animation class in it as a reference to implement the same behaviour in your code. Look at the _blit_draw() and several related functions after it in the sources.

Related

Collision detection between circle and rectangle

I wrote some code to show a circle and a rectangle randomly on the screen with PyQt6. and I want to detect if these two objects have a collision then I make them red otherwise I make them green.
But how should I detect whether there is a collision or not?
here is my code
from random import randint
from sys import argv
from PyQt6.QtCore import QRect, QTimer, Qt, QMimeData
from PyQt6.QtGui import QColor, QKeyEvent, QMouseEvent, QPainter, QPen, QPaintEvent, QBrush, QDrag
from PyQt6.QtWidgets import QApplication, QVBoxLayout, QMainWindow, QPushButton
class Window(QMainWindow):
def __init__(self) -> None:
super().__init__()
screenWidth = 1920
screenHeight = 1080
self.isRunning = True
self.windowWidth = 1200
self.windowHeight = 800
self.clockCounterVariable = 0
self.milSec = 0
self.seconds = 0
self.minutes = 0
self.hours = 0
self.setWindowTitle("Smart rockets")
self.setGeometry((screenWidth - self.windowWidth) // 2, (screenHeight - self.windowHeight) // 2, self.windowWidth, self.windowHeight)
self.setLayout(QVBoxLayout())
self.setStyleSheet("background-color:rgb(20, 20, 20);font-size:20px;")
self.clock = QTimer(self)
self.clock.timeout.connect(self.clockCounter)
self.clock.start(10)
button = QPushButton("Refresh", self)
button.setGeometry(20,self.windowHeight - 60,self.windowWidth - 40,40)
button.setStyleSheet("background-color:rgb(80, 80, 80);font-size:20px;")
button.setCheckable(True)
button.clicked.connect(self.refreshRectAndCircle)
rectangleWidth = randint(50, 500)
rectangleHeight = randint(50, 500)
self.rectangle = QRect(randint(0, self.windowWidth - rectangleWidth), randint(0, self.windowHeight - rectangleHeight - 80), rectangleWidth, rectangleHeight)
circleRadius = randint(50, 200)
self.circle = QRect(randint(0, self.windowWidth - circleRadius), randint(0, self.windowHeight - circleRadius - 80), circleRadius, circleRadius)
self.show()
def dragEnterEvent(self, event) -> super:
event.accept()
def keyPressEvent(self, event: QKeyEvent) -> super:
key = QKeyEvent.key(event)
if key == 112 or key == 80: # P/p
if self.isRunning:
self.clock.stop()
print("pause process")
self.isRunning = False
else:
print("continue process")
self.isRunning = True
self.clock.start(10)
elif (key == 115) or (key == 83): # S/s
self.closeWindow()
return super().keyPressEvent(event)
def mousePressEvent(self, event: QMouseEvent) -> super:
if event.buttons() == Qt.MouseButton.LeftButton:
if self.isRunning:
self.clock.stop()
print("pause process")
self.isRunning = False
else:
print("continue process")
self.isRunning = True
self.clock.start(10)
return super().mousePressEvent(event)
def clockCounter(self) -> None:
self.clockCounterVariable += 1
self.update()
def paintEvent(self, a0: QPaintEvent) -> super:
painter = QPainter()
self.milSec = self.clockCounterVariable
self.seconds, self.milSec = divmod(self.milSec, 100)
self.minutes, self.seconds = divmod(self.seconds, 60)
self.hours, self.minutes = divmod(self.minutes, 60)
painter.begin(self)
painter.setPen(QPen(QColor(255, 128, 20), 1, Qt.PenStyle.SolidLine))
painter.drawText(QRect(35, 30, 400, 30), Qt.AlignmentFlag.AlignLeft, "{:02d} : {:02d} : {:02d} : {:02d}".format(self.hours, self.minutes, self.seconds, self.milSec))
if self.collided():
painter.setPen(QPen(QColor(255, 20, 20), 0, Qt.PenStyle.SolidLine))
painter.setBrush(QBrush(QColor(128, 20, 20), Qt.BrushStyle.SolidPattern))
else:
painter.setPen(QPen(QColor(20, 255, 20), 0, Qt.PenStyle.SolidLine))
painter.setBrush(QBrush(QColor(20, 128, 20), Qt.BrushStyle.SolidPattern))
painter.drawRect(self.rectangle)
painter.drawEllipse(self.circle)
painter.end()
return super().paintEvent(a0)
def refreshRectAndCircle(self) -> None:
rectangleWidth = randint(50, 500)
rectangleHeight = randint(50, 500)
self.rectangle = QRect(randint(0, self.windowWidth - rectangleWidth), randint(0, self.windowHeight - rectangleHeight - 80), rectangleWidth, rectangleHeight)
circleRadius = randint(50, 200)
self.circle = QRect(randint(0, self.windowWidth - circleRadius), randint(0, self.windowHeight - circleRadius - 80), circleRadius, circleRadius)
self.update()
def collided(self) -> bool:
# return True if collided and return False if not collided
circle = self.circle
rect = self.rectangle
if __name__ == "__main__":
App = QApplication(argv)
window = Window()
App.exec()
how should I detect whether there is a collision between the circle and the rectangle or not?
While you can achieve this with math functions, luckily Qt provides some useful functions that can make this much easier.
You can achieve this with three steps - or even just one (see the last section).
Check the center of the circle
If the center of the circle is within the boundaries of the rectangle, you can always assume that they collide. You're using a QRect, which is a rectangle that is always aligned to the axis, making things much easier.
Mathematically speaking you just need to ensure that the X of the center is between the smallest and biggest X of the left and right vertical lines of the rectangle, then the same for the Y.
Qt allows us to check if QRect.contains() the QRect.center() of the circle.
def collided(self) -> bool:
center = self.circle.center()
if self.rectangle.contains(center):
return True
Check the vertexes of the rectangle
If the length between the center of the circle and any of the vertexes of the rectangle is smaller than the radius, you can be sure that they are within the circle area.
Using the basic Pythagorean equation, you can know the hypotenuse created between the center and each of the vertexes of the rectangle, and if the hypotenuse is smaller than the radius, it means that they are within the circle.
With Qt we can use QLineF with the center and the vertexes (topLeft(), topRight(), bottomRight() and bottomLeft()), whenever any of the lengths is smaller than the radius, it means that the vertex is within the circle. Using QPolygonF we can easily iterate through all vertexes in a for loop.
# ...
center = QPointF(center)
radius = self.circle.width() / 2
corners = QPolygonF(QRectF(self.rectangle))[:4]
for corner in corners:
if QLineF(center, corner).length() < radius:
return True
Check the closest side of the rectangle
It is possible that the circle only collides with a side of the rectangle: the center of the circle is outside of the rectangle, and none of the vertexes are within the circle.
Consider this case:
In this situations, the collision always happens whenever the perpendicular line of the closest side of the rectangle is smaller than the radius:
Using math, we'll need to get the line perpendicular to the closest side, going toward the center of the circle, computing the angle between the side and the lines connecting the center with each vertex (shown in orange above), then with the help of some trigonometry, get the cathetus of one of the triangles (shown in red): if the length of that line is smaller than the radius, the shapes collide.
Luckily again, Qt can help us. We can get the two closest points using the lines created in the section "Check the vertexes of the rectangle" above, get the side of those points and compute a perpendicular angle that will be used to create a "diameter": starting from the center, we create two lines with opposite angles and the radius with the fromPolar(), then create the actual diameter with the external points of those lines. Finally, we check if that diameter intersects() with the side.
And this is the final function:
def collided(self) -> bool:
center = self.circle.center()
if self.rectangle.contains(center):
return True
# use floating point based coordinates
center = QPointF(center)
radius = self.circle.width() / 2
corners = QPolygonF(QRectF(self.rectangle))[:4]
lines = []
for corner in corners:
line = QLineF(center, corner)
if line.length() < radius:
return True
lines.append(line)
# sort lines by their lengths
lines.sort(key=lambda l: l.length())
# create the side of the closest points
segment = QLineF(lines[0].p2(), lines[1].p2())
# the perpendicular angle, intersecting with the center of the circle
perpAngle = (segment.angle() + 90) % 360
# the ends of the "diameter" per pendicular to the side
d1 = QLineF.fromPolar(radius, perpAngle).translated(center)
d2 = QLineF.fromPolar(radius, perpAngle + 180).translated(center)
# the actual diameter line
diameterLine = QLineF(d1.p2(), d2.p2())
# get the intersection type
intersection = diameterLine.intersects(segment, QPointF())
return intersection == QLineF.BoundedIntersection
Further considerations
when dealing with geometric shapes, you should consider using QPainterPath which actually makes the above extremely simpler:
def collided(self) -> bool:
circlePath = QPainterPath()
circlePath.addEllipse(QRectF(self.circle))
return circlePath.intersects(QRectF(self.rectangle))
Qt has a powerful (yet complex) Graphics View Framework that makes graphics and user interaction much more intuitive and effective; while the QPainter API is certainly easier for simpler cases, it may result in cumbersome (and difficult to debug) code as soon as your program requirements grow in complexity;
QMainWindow has its own, private and inaccessible layout manager, you cannot call setLayout() on it; use setCentralWidget() and set a layout to that widget eventually;
never use generic stylesheet properties for parent widgets (as you did for the main window) because it may result in awkward drawing of complex widgets like scroll areas; always use selector types for windows and containers instead;
unless you actually need to paint on the QMainWindow contents (which is a rare occurrence), you should always implement the paintEvent() on its central widget instead; otherwise, if you don't need QMainWindow features (menubar, statusbar, dock widgets and toolbars), just use a QWidget;
QTimer is not reliable for precise time measurement: if any function called while it's running requires more time than the timeout interval, the connected function will always be called afterwards; use QElapsedTimer instead;
in paintEvent() just use painter = QPainter(self), remove painter.begin(self) (it's implicit using the above) and painter.end() (unnecessary, since it's automatically destroyed when the function returns);
don't create unnecessary instance attributes (self.milSec, self.seconds, etc) that will be almost certainly overwritten sooner or later, and that you're not using elsewhere; the paint event must always return as soon as possible and must be always optimized as much as possible;

pyqtgraph cuts off tick labels if showGrid is called

I am attempting to show some subplots using pyqtgraph.
I would like to set x and/or y axis limits and a grid on these plots.
Here an example without the grid:
import numpy as np
import pyqtgraph as pg
x = np.linspace(0, 2*np.pi, 1000)
y = np.sin(x)
app = pg.mkQApp()
win = pg.GraphicsLayoutWidget(show = True)
p1 = win.addPlot(row = 0, col = 0)
p1.plot(x = x, y = y, pen = pg.mkPen(color = 'b', width = 1))
p1.setXRange(0, 2*np.pi, padding = 0)
p1.setYRange(-1, 1, padding = 0)
app.exec_()
If I turn on the grid with:
...
p1.showGrid(x = True, y = True)
app.exec_()
then I get:
As you can see in the bottom left corner, x and y first ticks are cutted off and this issue shows up only when I turn on the grid.
How can I show the grid on my plots (and set properly x and y limits) without cutting off axis tick labels?
This issue appears to have been created in attempt to fix another detailed here on github: https://github.com/pyqtgraph/pyqtgraph/issues/732
In the pyqtgraph/AxisItem.py source, if you hash out the below two lines (1162, 1163) the issue is corrected, but artifacts will show on the axis again:
# Draw all text
if self.style['tickFont'] is not None:
p.setFont(self.style['tickFont'])
p.setPen(self.textPen())
# bounding = self.boundingRect().toAlignedRect()
# p.setClipRect(bounding) # Ignore Clip
for rect, flags, text in textSpecs:
p.drawText(rect, int(flags), text)
Another option, as listed on the github issue link, would be to only show axis ticks that fit the bounding box (overwrite in AxisItem.py source):
# Draw all text
if self.style['tickFont'] is not None:
p.setFont(self.style['tickFont'])
p.setPen(self.textPen())
bounding = self.boundingRect()
for rect, flags, text in textSpecs:
# PATCH: only draw text that completely fits
if bounding.contains(rect):
p.drawText(rect, int(flags), text)
My preferred method though has been to set the grid via the 'top' and 'right' axis and disable it from 'left' and 'bottom', where the tick labels reside. Give this a try:
import numpy as np
import pyqtgraph as pg
x = np.linspace(0, 2*np.pi, 1000)
y = np.sin(x)
app = pg.mkQApp()
win = pg.GraphicsLayoutWidget(show = True)
p1 = win.addPlot(row = 0, col = 0)
p1.plot(x = x, y = y, pen = pg.mkPen(color = 'b', width = 1))
p1.setXRange(0, 2*np.pi, padding = 0)
p1.setYRange(-1, 1, padding = 0)
p1.showGrid(x = True, y = True)
p1.getAxis('left').setGrid(False) # Disable left axis grid
p1.getAxis('bottom').setGrid(False) # Dsiable bottom axis grid
for key in ['right', 'top']:
p1.showAxis(key) # Show top/right axis (and grid, since enabled here)
p1.getAxis(key).setStyle(showValues=False) # Hide tick labels on top/right
app.exec_()
Resulting plot: result
On line 581 of pyqtgraph/AxisItem.py source you can see how the tick bounding rectangles are handled differently if 'self.grid' is True or False:
def boundingRect(self):
linkedView = self.linkedView()
if linkedView is None or self.grid is False:
rect = self.mapRectFromParent(self.geometry())
## extend rect if ticks go in negative direction
## also extend to account for text that flows past the edges
tl = self.style['tickLength']
if self.orientation == 'left':
rect = rect.adjusted(0, -15, -min(0, tl), 15)
elif self.orientation == 'right':
rect = rect.adjusted(min(0, tl), -15, 0, 15)
elif self.orientation == 'top':
rect = rect.adjusted(-15, 0, 15, -min(0, tl))
elif self.orientation == 'bottom':
rect = rect.adjusted(-15, min(0, tl), 15, 0)
return rect
else:
return self.mapRectFromParent(self.geometry()) | linkedView.mapRectToItem(self, linkedView.boundingRect())

How can I add collisions to a list of objects (circles) in tkinter?

import time
from tkinter import *
import random
class SpaceField:
def __init__(self):
self.window = Tk()
self.window.title("Asteriods")
self.canvas = self.canvas_display() #creates canvas
self.asteriods = self.asteriod_creation_seperation() #creates asteroids
self.active = True
self.move_active() #Moves asteroids
self.canvas.update()
def asteriod_creation_seperation(self): #creation of multple asteriods
asteriod_spacingx = random.randint(1,800)
asteriod_spacingy = random.randint(1,800)
asteriod_list = list() # could list([])
for i in range(15):
asteriod = self.canvas.create_oval( 30, 50 , 80 , 100 , tags="asteriod", width=2, outline="white")
asteriod_list.append("asteriod")
self.canvas.move(asteriod, asteriod_spacingx, asteriod_spacingy)
asteriod_spacingx = random.randint(1,500)
asteriod_spacingy = random.randint(1,500)
print(asteriod_spacingy)
return asteriod_list
Asteroid Creation. Creates asteroids and gives them random positions.
def asteriod_update(self): #asteriods movement method #MAin problem
x12 = 1
self.canvas.move("asteriod", 3, x12)
pos = self.canvas.coords("asteriod")
print(pos)
if (pos)[2] > 500:
x12 *= 5
I think this is where I need to add the collision detection. I just have no idea how to combine the lists of the circles and the collisions.
def move_active(self): #Basically a true loop
if self.active:
self.asteriod_update()
self.window.after(40, self.move_active)
def canvas_display(self): #canvas
canvas = Canvas(self.window, width=500, height=400, background='black')
canvas.pack(expand=True, fill="both")
canvas.update()
return canvas
Canvas display nothing special
def run(self):
self.window.mainloop()
if __name__ == '__main__':
SpaceF = SpaceField()
SpaceF.run()
Asteroids is a classic game but there were a number of problems in your code. The main one was calling move_active during initialization. This prevented the code from completing its mainloop initialization.
The other problem was the asteroid_update method that basically didn't do anything, also using tags to control all asteroids didn't work either.
Everything else was OK, although you might consider using polygons.
Here is one way to produce a bouncing objects program. I've inserted remarks that describe the methods used.
Objects change the speed and direction when they hit the boundary so their trajectories are randomized.
from tkinter import *
from random import randint as rnd
class SpaceField:
def __init__(self):
self.window = Tk()
self.window.title("Asteriods")
# Define canvas size and active flag
self.wide, self.high, self.active = 500, 400, True
self.canvas_display()
self.asteriod_creation_seperation()
def asteriod_creation_seperation(self):
self.asteroids, self.speed = [], []
size, radius = 50, 25
for i in range(15):
spacex = rnd(size, self.wide - size)
spacey = rnd(size, self.high - size)
self.asteroids.append( # Store oval item id
self.canvas.create_oval(
spacex, spacey, spacex+size, spacey+size,
width=2, tags = "asteriod", outline = "white"))
self.speed.append((rnd(1,4),rnd(1,4))) # Store random speed x, y
def asteriod_update(self): # MAIN DRIVER: Work on ALL asteroids
for i, a in enumerate(self.asteroids):
xx, yy = self.speed[i] # get speed data
x, y, w, h = self.canvas.coords(a)
# check for boundary hit then change direction and speed
if x < 0 or w > self.wide:
xx = -xx * rnd(1, 4)
if y < 0 or h > self.high:
yy = -yy * rnd(1, 4)
# Limit max and min speed then store it
self.speed[i] = (max( -4, min( xx, 4)), max( -4, min( yy, 4 )))
self.canvas.move(a, xx, yy) # update asteroid position
def move_active(self):
if self.active:
self.asteriod_update()
self.window.after(40, self.move_active)
def canvas_display(self):
self.canvas = Canvas(
self.window, width = self.wide,
height = self.high, background = "black")
self.canvas.pack(expand = True, fill = "both")
def run(self): # Begin asteroids here so that mainloop is executed
self.window.after(200, self.move_active)
self.window.mainloop()
if __name__ == "__main__":
SpaceF = SpaceField()
SpaceF.run()

How do I have an object rebound off the canvas border?

I am using the canvas widget from tkinter to create an ellipse and have it move around in the canvas.
However when the ellipse comes in contact with the border it gets stuck to wall instead of bouncing off.
I'm struggling with debugging the code, thanks in advance!
from tkinter import *
from time import *
import numpy as np
root = Tk()
root.wm_title("Bouncing Ball")
canvas = Canvas(root, width=400, height=400, bg="black")
canvas.grid()
size=10
x = 50
y = 50
myBall = canvas.create_oval(x-size, y-size, x+size, y+size, fill = "red")
while True:
root.update()
root.after(50)
dx = 5
dy = 0
#separating x and y cooridnates from tuple of canvas.coords
x = canvas.coords(myBall)[0]+10
y = canvas.coords(myBall)[1]+10
coordinates = np.array([x, y], dtype = int)
#Checking boundaries
if coordinates[0]-size <= 0:
dx = -1*dx
if coordinates[0]+size >= 400:
dx = -1*dx
if coordinates[1]-size <= 0:
dy = -1*dy
if coordinates[1]+size >= 400:
dy = -1*dy
print(coordinates) #Used to see what coordinates are doing
canvas.move(myBall, dx, dy) #Move ball by dx and dy
Here is a simple way to organize your bouncing ball program, and get you started with GUI programming:
While loops don't work well with a GUI mainloop; it is also not necessary to call update, the mainloop handles that.
Repeated actions are best handles with root.after.
I extracted the bounce logic inside a function bounce that calls itself using root.after. You will see that I simplified the logic.
I also parametrized the canvas size.
The initial speed components dx and dy are randomly chosen from a list of possible values so the game is not too boring.
Here is how it looks:
import tkinter as tk # <-- avoid star imports
import numpy as np
import random
WIDTH = 400
HEIGHT = 400
initial_speeds = [-6, -5, -4, 4, 5, 6]
dx, dy = 0, 0
while dx == dy:
dx, dy = random.choice(initial_speeds), random.choice(initial_speeds)
def bounce():
global dx, dy
x0, y0, x1, y1 = canvas.coords(my_ball)
if x0 <= 0 or x1 >= WIDTH: # compare to left of ball bounding box on the left wall, and to the right on the right wall
dx = -dx
if y0 <= 0 or y1 >= HEIGHT: # same for top and bottom walls
dy = -dy
canvas.move(my_ball, dx, dy)
root.after(50, bounce)
if __name__ == '__main__':
root = tk.Tk()
root.wm_title("Bouncing Ball")
canvas = tk.Canvas(root, width=400, height=400, bg="black")
canvas.pack(expand=True, fill=tk.BOTH)
size=10
x = 50
y = 50
my_ball = canvas.create_oval(x-size, y-size, x+size, y+size, fill="red")
bounce()
root.mainloop()
It's just basic math. The ball moves left when you subtract some amount from the x coordinate. If it hits the left wall and you want it to bounce to the right, you need to stop subtracting from x and start adding to x. The same is true for the y coordinate.

"tkinter.TclError: invalid command name" error after calling root.destroy()

I am in the process of learning tkinter on Python 3.X. I am writing a simple program which will get one or more balls (tkinter ovals) bouncing round a rectangular court (tkinter root window with a canvas and rectangle drawn on it).
I want to be able to terminate the program cleanly by pressing the q key, and have managed to bind the key to the root and fire the callback function when a key is pressed, which then calls root.destroy().
However, I'm still getting errors of the form _tkinter.TclError: invalid command name ".140625086752360" when I do so. This is driving me crazy. What am I doing wrong?
from tkinter import *
import time
import numpy
class Ball:
def bates():
"""
Generator for the sequential index number used in order to
identify the various balls.
"""
k = 0
while True:
yield k
k += 1
index = bates()
def __init__(self, parent, x, y, v=0.0, angle=0.0, accel=0.0, radius=10, border=2):
self.parent = parent # The parent Canvas widget
self.index = next(Ball.index) # Fortunately, I have all my feathers individually numbered, for just such an eventuality
self.x = x # X-coordinate (-1.0 .. 1.0)
self.y = y # Y-coordinate (-1.0 .. 1.0)
self.radius = radius # Radius (0.0 .. 1.0)
self.v = v # Velocity
self.theta = angle # Angle
self.accel = accel # Acceleration per tick
self.border = border # Border thickness (integer)
self.widget = self.parent.canvas.create_oval(
self.px() - self.pr(), self.py() - self.pr(),
self.px() + self.pr(), self.py() + self.pr(),
fill = "red", width=self.border, outline="black")
def __repr__(self):
return "[{}] x={:.4f} y={:.4f} v={:.4f} a={:.4f} r={:.4f} t={}, px={} py={} pr={}".format(
self.index, self.x, self.y, self.v, self.theta,
self.radius, self.border, self.px(), self.py(), self.pr())
def pr(self):
"""
Converts a radius from the range 0.0 .. 1.0 to window coordinates
based on the width and height of the window
"""
assert self.radius > 0.0 and self.radius <= 1.0
return int(min(self.parent.height, self.parent.width)*self.radius/2.0)
def px(self):
"""
Converts an X-coordinate in the range -1.0 .. +1.0 to a position
within the window based on its width
"""
assert self.x >= -1.0 and self.x <= 1.0
return int((1.0 + self.x) * self.parent.width / 2.0 + self.parent.border)
def py(self):
"""
Converts a Y-coordinate in the range -1.0 .. +1.0 to a position
within the window based on its height
"""
assert self.y >= -1.0 and self.y <= 1.0
return int((1.0 - self.y) * self.parent.height / 2.0 + self.parent.border)
def Move(self, x, y):
"""
Moves ball to absolute position (x, y) where x and y are both -1.0 .. 1.0
"""
oldx = self.px()
oldy = self.py()
self.x = x
self.y = y
deltax = self.px() - oldx
deltay = self.py() - oldy
if oldx != 0 or oldy != 0:
self.parent.canvas.move(self.widget, deltax, deltay)
def HandleWallCollision(self):
"""
Detects if a ball collides with the wall of the rectangular
Court.
"""
pass
class Court:
"""
A 2D rectangular enclosure containing a centred, rectagular
grid of balls (instances of the Ball class).
"""
def __init__(self,
width=1000, # Width of the canvas in pixels
height=750, # Height of the canvas in pixels
border=5, # Width of the border around the canvas in pixels
rows=1, # Number of rows of balls
cols=1, # Number of columns of balls
radius=0.05, # Ball radius
ballborder=1, # Width of the border around the balls in pixels
cycles=1000, # Number of animation cycles
tick=0.01): # Animation tick length (sec)
self.root = Tk()
self.height = height
self.width = width
self.border = border
self.cycles = cycles
self.tick = tick
self.canvas = Canvas(self.root, width=width+2*border, height=height+2*border)
self.rectangle = self.canvas.create_rectangle(border, border, width+border, height+border, outline="black", fill="white", width=border)
self.root.bind('<Key>', self.key)
self.CreateGrid(rows, cols, radius, ballborder)
self.canvas.pack()
self.afterid = self.root.after(0, self.Animate)
self.root.mainloop()
def __repr__(self):
s = "width={} height={} border={} balls={}\n".format(self.width,
self.height,
self.border,
len(self.balls))
for b in self.balls:
s += "> {}\n".format(b)
return s
def key(self, event):
print("Got key '{}'".format(event.char))
if event.char == 'q':
print("Bye!")
self.root.after_cancel(self.afterid)
self.root.destroy()
def CreateGrid(self, rows, cols, radius, border):
"""
Creates a rectangular rows x cols grid of balls of
the specified radius and border thickness
"""
self.balls = []
for r in range(1, rows+1):
y = 1.0-2.0*r/(rows+1)
for c in range(1, cols+1):
x = 2.0*c/(cols+1) - 1.0
self.balls.append(Ball(self, x, y, 0.001,
numpy.pi/6.0, 0.0, radius, border))
def Animate(self):
"""
Animates the movement of the various balls
"""
for c in range(self.cycles):
for b in self.balls:
b.v += b.accel
b.Move(b.x + b.v * numpy.cos(b.theta),
b.y + b.v * numpy.sin(b.theta))
self.canvas.update()
time.sleep(self.tick)
self.root.destroy()
I've included the full listing for completeness, but I'm fairly sure that the problem lies in the Court class. I presume it's some sort of callback or similar firing but I seem to be beating my head against a wall trying to fix it.
You have effectively got two mainloops. In your Court.__init__ method you use after to start the Animate method and then start the Tk mainloop which will process events until you destroy the main Tk window.
However the Animate method basically replicates this mainloop by calling update to process events then time.sleep to waste some time and repeating this. When you handle the keypress and terminate your window, the Animate method is still running and attempts to update the canvas which no longer exists.
The correct way to handle this is to rewrite the Animate method to perform a single round of moving the balls and then schedule another call of Animate using after and provide the necessary delay as the after parameter. This way the event system will call your animation function at the correct intervals while still processing all other window system events promptly.

Categories

Resources