Situation
I have a PyQt5 app that shows lines, text and circles, it shows them correctly but the text rendering is a bit slow. I have a custom class for QGrapichsView that implement all this.
problem
When I set in the properties of the gv the following I start getting errors such as the example. The text and circles render correctly at a much faster render time(much better) but the lines get the error in rendering.
self.gl_widget = QOpenGLWidget()
format = QSurfaceFormat()
# format.setVersion(3, 0)
format.setProfile(QSurfaceFormat.CoreProfile)
self.gl_widget.setFormat(format)
self.setViewport(self.gl_widget)
the render of text get much much better and it shows them as it should. but a problem comes with the lines that start having strange behavior.
example with issue
example without issue
note how the width of the lines is variable even tough is set to a unique value, also, when I do a zoom out or zoom in, some of this lines appear and disappear randomly.
As soon as I use path item the problems begin, just a line item does not create this problem.
Does anybody have any idea what could this mean?
what to look for?
The issue is that the width of the lines are random, and not the set value I put in the code. Also when you zoom in or out, it disappears.
It seems to have something to do with the set width, as a bigger width helps, but does not remove it.
minimal reproducible example
import sys
from PyQt5.QtWidgets import QApplication, QGraphicsScene, QGraphicsTextItem
from PyQt5 import QtWidgets, QtCore, QtGui
from PyQt5.QtWidgets import QOpenGLWidget
import numpy as np
from PyQt5.QtGui import QPainterPath, QPen
from PyQt5.QtWidgets import QGraphicsPathItem, QGraphicsLineItem, QGraphicsPolygonItem
from PyQt5.QtGui import QPolygonF
from PyQt5.QtCore import QLineF, QPointF
from PyQt5.QtGui import QSurfaceFormat
class GraphicsView(QtWidgets.QGraphicsView):
def __init__(self):
super(GraphicsView, self).__init__()
self.pos_init_class = None
# "VARIABLES INICIALES"
self.scale_factor = 1.5
# "ASIGNAR LINEAS DE MARCO"
self.setFrameShape(QtWidgets.QFrame.VLine)
# "ACTIVAR TRACKING DE POSICION DE MOUSE"
self.setMouseTracking(True)
# "REMOVER BARRAS DE SCROLL"
self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
# "ASIGNAR ANCLA PARA HACER ZOOM SOBRE EL MISMO PUNTO"
self.setTransformationAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse)
self.setResizeAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse)
# "MEJORAR EL RENDER DE VECTORES"
self.setRenderHint(QtGui.QPainter.Antialiasing, False)
self.setRenderHint(QtGui.QPainter.SmoothPixmapTransform, False)
self.setRenderHint(QtGui.QPainter.TextAntialiasing, False)
self.setRenderHint(QtGui.QPainter.HighQualityAntialiasing, False)
self.setRenderHint(QtGui.QPainter.NonCosmeticDefaultPen, True)
self.setOptimizationFlag(QtWidgets.QGraphicsView.DontAdjustForAntialiasing, True)
self.setOptimizationFlag(QtWidgets.QGraphicsView.DontSavePainterState, True)
self.setCacheMode(QtWidgets.QGraphicsView.CacheBackground)
self.setViewportUpdateMode(QtWidgets.QGraphicsView.BoundingRectViewportUpdate)
#Try OpenGL stuff
# self.gl_widget = QOpenGLWidget()
# self.setViewport(self.gl_widget)
self.gl_widget = QOpenGLWidget()
format = QSurfaceFormat()
format.setVersion(2, 8)
format.setProfile(QSurfaceFormat.CoreProfile)
self.gl_widget.setFormat(format)
self.setViewport(self.gl_widget)
def mousePressEvent(self, event):
pos = self.mapToScene(event.pos())
# "PAN MOUSE"
if event.button() == QtCore.Qt.MiddleButton:
self.pos_init_class = pos
QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.ClosedHandCursor)
super(GraphicsView, self).mousePressEvent(event)
def mouseReleaseEvent(self, event):
# PAN Y RENDER TEXT
if self.pos_init_class and event.button() == QtCore.Qt.MiddleButton:
# PAN
self.pos_init_class = None
QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.ArrowCursor)
super(GraphicsView, self).mouseReleaseEvent(event)
def mouseMoveEvent(self, event):
if self.pos_init_class:
# "PAN"
delta = self.pos_init_class - self.mapToScene(event.pos())
r = self.mapToScene(self.viewport().rect()).boundingRect()
self.setSceneRect(r.translated(delta))
super(GraphicsView, self).mouseMoveEvent(event)
def wheelEvent(self, event):
old_pos = self.mapToScene(event.pos())
# Determine the zoom factor
if event.angleDelta().y() > 0:
zoom_factor = self.scale_factor
else:
zoom_factor = 1 / self.scale_factor
# Apply the transformation to the view
transform = QtGui.QTransform()
transform.translate(old_pos.x(), old_pos.y())
transform.scale(zoom_factor, zoom_factor)
transform.translate(-old_pos.x(), -old_pos.y())
# Get the current transformation matrix and apply the new transformation to it
current_transform = self.transform()
self.setTransform(transform * current_transform)
def zoom_extent(self):
x_range, y_range, h_range, w_range = self.scene().itemsBoundingRect().getRect()
rect = QtCore.QRectF(x_range, y_range, h_range, w_range)
self.setSceneRect(rect)
unity = self.transform().mapRect(QtCore.QRectF(0, 0, 1, 1))
self.scale(1 / unity.width(), 1 / unity.height())
viewrect = self.viewport().rect()
scenerect = self.transform().mapRect(rect)
factor = min(viewrect.width() / scenerect.width(), viewrect.height() / scenerect.height())
self.scale(factor, factor)
class MainWindow(QtWidgets.QMainWindow):
def __init__(self, parent=None):
super().__init__(parent)
self.view = GraphicsView()
self.scene = QGraphicsScene()
self.view.setScene(self.scene)
self.generate_random_lines()
self.setCentralWidget(self.view)
self.showMaximized()
self.view.zoom_extent()
def rotate_vector(self, origin, point, angle):
"""
ROTATE A POINT COUNTERCLOCKWISE BY A GIVEN ANGLE AROUND A GIVEN ORIGIN. THE ANGLE SHOULD BE GIVEN IN RADIANS.
:param origin: SOURCE POINT ARRAYS, [X_SOURCE, Y_SOURCE], LEN N
:param point: DESTINATION POINT, [X_DEST, Y_DEST], LEN N
:param angle: ARRAY OF ANGLE TO ROTATE VECTOR (ORIGIN --> POINT), [ANG], LEN N
:return:
"""
ox, oy = origin
px, py = point
qx = ox + np.cos(angle) * (px - ox) - np.sin(angle) * (py - oy)
qy = oy + np.sin(angle) * (px - ox) + np.cos(angle) * (py - oy)
return qx, qy
def create_line_with_arrow_path(self, x1, y1, x2, y2, arr_width, arr_len):
"""
This function creates a line with an arrowhead at the end.
The line is created between two points (x1, y1) and (x2, y2).
The arrowhead is defined by its width (arr_width) and length (arr_len).
Returns a QGraphicsPathItem with the line and arrowhead.
"""
# Initialize the path for the line and arrowhead
path = QPainterPath()
path.moveTo(x1, y1)
path.lineTo(x2, y2)
# Calculate the midpoint of the line
mid_x = (x1 + x2) / 2
mid_y = (y1 + y2) / 2
# Define the points of the arrowhead
arrow_x = np.array([arr_width, -arr_len, -arr_width, -arr_len, arr_width]) * 5
arrow_y = np.array([0, arr_width, 0, -arr_width, 0]) * 5
arrow_x += mid_x
arrow_y += mid_y
# Calculate the angle of the line
angle = np.rad2deg(np.arctan2(y2 - y1, x2 - x1))
# Rotate the arrowhead points to align with the line
origin = (np.array([mid_x, mid_x, mid_x, mid_x, mid_x]), np.array([mid_y, mid_y, mid_y, mid_y, mid_y]))
point = (arrow_x, arrow_y)
self.x_init, self.y_init = self.rotate_vector(origin, point, np.deg2rad(angle))
# Add the arrowhead to the path
arrow_path = QtGui.QPainterPath()
arrow_path.moveTo(self.x_init[0], self.y_init[0])
for i in range(1, len(arrow_x)):
arrow_path.lineTo(self.x_init[i], self.y_init[i])
path.addPath(arrow_path)
# Create a QGraphicsPathItem with the line and arrowhead
item = QGraphicsPathItem(path)
pen = QPen()
pen.setWidthF(0.1)
item.setPen(pen)
return item, angle
def create_line_with_arrow_item(self, x1, y1, x2, y2, arr_width, arr_len):
# Calculate the midpoint of the line
mid_x = (x1 + x2) / 2
mid_y = (y1 + y2) / 2
# Define the coordinates for the arrow
arrow_x = np.array([arr_width, -arr_len, -arr_width, -arr_len, arr_width]) * 10
arrow_y = np.array([0, arr_width, 0, -arr_width, 0]) * 10
arrow_x += mid_x
arrow_y += mid_y
# Calculate the angle of the line
angle = np.rad2deg(np.arctan2(y2 - y1, x2 - x1))
# Rotate the arrow to align with the line
origin = (np.array([mid_x, mid_x, mid_x, mid_x, mid_x]), np.array([mid_y, mid_y, mid_y, mid_y, mid_y]))
point = (arrow_x, arrow_y)
x_init, y_init = self.rotate_vector(origin, point, np.deg2rad(angle))
# Create the line and arrow
line = QLineF(x1, y1, x2, y2)
arrow = QPolygonF([QPointF(x_init[0], y_init[0]),
QPointF(x_init[1], y_init[1]),
QPointF(x_init[2], y_init[2]),
QPointF(x_init[3], y_init[3]),
QPointF(x_init[4], y_init[4])])
item = QGraphicsLineItem(line)
item_arrow = QGraphicsPolygonItem(arrow)
# Set the pen for both line and arrow
pen = QPen()
pen.setWidthF(1)
item.setPen(pen)
item_arrow.setPen(pen)
# Return the line and arrow items
return item, item_arrow, angle
def generate_random_lines(self):
case = 'issue'
x = np.array([0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]) * 10
y = np.array([0, 20, 10, 0, 35, 90, 10, 60, 60, 90, 100]) * 10
for pos, i in enumerate(range(len(x) - 1)):
x1 = x[i]
y1 = y[i]
x2 = x[i + 1]
y2 = y[i + 1]
if case in ['issue']:
#add lines
path, angle = self.create_line_with_arrow_path(x1, y1, x2, y2, 0.5, 1.5)
self.scene.addItem(path)
# add text
text1 = QGraphicsTextItem()
text1.setPlainText(str(pos))
text1.setPos(x1, y1)
text1.setRotation(angle)
self.scene.addItem(text1)
else:
#add lines
line, arrow, angle = self.create_line_with_arrow_item(x1, y1, x2, y2, 0.5, 1.5)
self.scene.addItem(line)
self.scene.addItem(arrow)
# add text
text1 = QGraphicsTextItem()
text1.setPlainText(str(pos))
text1.setPos(x1, y1)
text1.setRotation(angle)
self.scene.addItem(text1)
if __name__ == "__main__":
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec_())
figures for minimal example
this is an example whit the issue
this is an example without the issue
The issue is caused by two reasons.
Sampling of the OpenGL surface
When an analog object is shown in a digital context, aliasing always happens. That's the basic difference between real and integer numbers.
Now, the "issue" with OpenGL (and, from my understanding, any 3D basic visualization) is that if an object doesn't fill a whole pixel, by default that pixel won't be shown.
Anti-aliasing allows to tell if a pixel should be shown even if only a part of that pixel may be covered by the object. OpenGL is capable of multisample anti-aliasing, which provides an optimized spacial anti-aliasing: the amount of samples tells the engine if a pixel should be shown (and how) or not.
I'm not completely sure on how Qt decides the default sampling (my assumption is that it's based on the detected screen capabilities), but, in my tests and according to the documentation, the default is -1, meaning that no multisampling is enabled.
The convention I found normally uses 16 samples as default, so you need to do the following:
self.gl_widget = QOpenGLWidget()
format = QSurfaceFormat()
format.setSamples(16)
# ...
The pen width is too small
You're using a default pen width of 0.1, which is extremely small. Even with the standard raster rendering (without QOpenGlWidget as viewport), the pen would be almost invisible.
Imagine having a thread (I mean a physical yarn) that is very small in size: you can see it if you're close to it, but as you get far from it you'll eventually stop seeing it at some point.
Set a cosmetic pen whenever the scale is too big
The solution is quite simple, conceptually speaking: use the actual size as long as the scaling (the "zoom factor") allows to properly see the object.
Making it in practice requires some ingenuity.
Take these considerations for the following example:
I'm assuming a very basic scenario based on your example; for obvious reasons, you may need some further implementation, also considering that you're using different pen widths depending on the objects;
I'm using a mix-in class that takes advantage of the cooperative multi-inheritance followed by PyQt;
the implementation is quite basic and assumes that you always call setPen() to set a "default pen width"; you can obviously make your own classes using the pen width as argument in the constructor;
the "cosmetic pen" choice is based on arbitrary values; the assumption is that a pen becomes "non cosmetic" whenever a pixel ratio of penWidth * 4 would theoretically cover a whole pixel;
class VisibleGlShapeItem(object):
'''
A pseudo class that potentially overrides setPen() and provides a
custom method to override the default setPen() implementation.
'''
penWidth = None
def setPen(self, pen):
super().setPen(pen)
if self.penWidth is None:
self.penWidth = pen.widthF()
def setCosmeticPen(self, cosmeticScale):
if self.penWidth is None:
self.penWidth = .1
cosmetic = .5 / cosmeticScale > self.penWidth
pen = self.pen()
if pen.isCosmetic() != cosmetic:
pen.setCosmetic(cosmetic)
if cosmetic:
pen.setWidthF(.5)
else:
pen.setWidthF(self.penWidth)
super().setPen(pen)
# mixin classes creation
class VisibleGlPathItem(VisibleGlShapeItem, QGraphicsPathItem): pass
class VisibleGlLineItem(VisibleGlShapeItem, QGraphicsLineItem): pass
class GraphicsView(QtWidgets.QGraphicsView):
def __init__(self):
# ...
self.setRenderHint(QtGui.QPainter.Antialiasing) # mandatory
# ...
self.gl_widget = QOpenGLWidget()
format.setSamples(16)
# ...
def wheelEvent(self, event):
# ...
self.updatePens()
def showEvent(self, event):
# ensure that updatePens is called at least on first start
super().showEvent(event)
if not event.spontaneous():
self.updatePens()
def updatePens(self):
# get the minimum transformation scale; while you seem to be using
# a fixed ratio, a reference should always be considered
scale = min(self.transform().m11(), self.transform().m22())
for item in self.items():
if isinstance(item, QGraphicsPathItem):
item.setCosmeticPen(scale)
class MainWindow(QtWidgets.QMainWindow):
# ...
def create_line_with_arrow_path(self, x1, y1, x2, y2, arr_width, arr_len):
# ...
item = VisibleGlPathItem(path)
pen = QPen()
pen.setWidthF(0.1)
item.setPen(pen)
return item, angle
Final notes
avoid unnecessary renderHint flags; by default, QGraphicsView only uses TextAntialiasing, so, setting any other flag to False is completely pointless; also, both HighQualityAntialiasing and NonCosmeticDefaultPen are obsolete;
unless you actually need numpy for other reasons, avoid its requirement for mathematical purposes: either use the math module, or Qt capabilities; for instance, if you need to "rotate" a point around another, you can use QLineF, as it's normally quite fast and has the major benefit of better readability:
vector = QLineF(p1, p2)
vector.setAngle(angle)
newP2 = vector.p2()
merge/group items; lines shouldn't be separated by their arrows; if they share the same pen, just use a single QPainterPath (and QGraphicsPathItem), otherwise make the arrows as child items of the line;
use caching: there's no point in creating new paths for each arrow every time; just create one (using a 0-reference point) and add a translated copy to the new path whenever you need it; the result is conceptually the same, with the benefit of working on the C++ side of things;
setFrameShape(QtWidgets.QFrame.VLine) only makes sense for separators; don't use it for the wrong reason or widget;
setOverrideCursor() is intended for the whole application, and you shouldn't use it to temporarily change the cursor of a single widget; use setCursor() and unsetCursor() instead;
Related
I want to make many circles to move on a given trajectory simultaneously. Everything works fine only with a single figure moving. Whenever I add another figure it starts speeding up until it starts freezing apparently. This happens no matter what I use threads or canvas.move() and canvas.after() methods.
Actually this is also quite weird, because with my full version of code it started to slow down after adding more figures. The one that I sent is simplified in order to show the issue with moving. Maybe that happens, because I used my own method to draw trajectory lines with many little squares, but that is not the point.
What can be done to move figures at the same time with the same speed without that much of a lag? I wanted to try to use procceses instead of threads, but did not really understand how they work and I doubt that would change anything significantly.
Maybe you could also give an advice on how to work with individual pixels in canvas without drawing rectangle with width of one pixel?
EDIT:
Forgot to mention. For some reason even if there is a single figure moving, if I move my mouse figure's moving will slow down until I stop touching the mouse. I suppose the reason is that tkinter starts registering events, but I need it to draw the trajectory, so removing is not really an option. What can be done to get rid of this issue?
import math
import tkinter as tk
from enum import Enum
import threading
class Trajectory:
# This trajectory should do the same trick both for circles, squares and maybe images
# x0, y0 - start point, x1, y1 - end point
def __init__(self, x0, y0, x1, y1, id, diameter):
print('aaaa', id)
self.x0 = x0
self.y0 = y0
self.x1 = x1
self.y1 = y1
self.id = id
self.sign = 1
self.diameter = diameter
self.dir = self.get_normalized_dir()
def has_arrived(self, x, y):
return math.sqrt((self.x1 - (x + self.diameter // 2)) * (self.x1 - (x + self.diameter // 2)) +
(self.y1 - (y + self.diameter // 2)) * (self.y1 - (y + self.diameter // 2))) < 1
def get_normalized_dir(self):
L = math.sqrt((self.x1 - self.x0) * (self.x1 - self.x0) + (self.y1 - self.y0) * (self.y1 - self.y0))
return (self.x1 - self.x0) / L, (self.y1 - self.y0) / L
def swap_points(self):
self.x0, self.y0, self.x1, self.y1 = self.x1, self.y1, self.x0, self.y0
def __str__(self):
return f'x0 {self.x0} y0 {self.y0} x1 {self.x1} y1 {self.y1} id {self.id} sign {self.sign}'
class App(tk.Tk):
def __init__(self):
# action_33 was intented as an Easter egg to smth (at least I think so). However,
# I forgot what it meant :(
super().__init__()
self.bind('<Motion>', self.on_mouse)
self.geometry('400x400')
self.resizable(False, False)
self.canvas = tk.Canvas(self, bg='white', width=400, height=400)
self.canvas.pack(fill="both", expand=True)
self.start_point = []
self.end_point = []
self.is_drawing = False
self.OUTLINE = 'black'
self.canvas.bind("<Button-1>", self.callback)
self.title('Object trajetory')
self.bg_line = None
self.figure_color = 'green'
self.figures = [] # will store only trajectory class
self.diameter = 40
def move_figures(self):
# if not self.is_drawing:
for figure in self.figures:
self.canvas.move(figure.id, figure.dir[0] * 0.1 * figure.sign, figure.dir[1] * 0.1 * figure.sign)
if figure.has_arrived(self.canvas.coords(figure.id)[0], self.canvas.coords(figure.id)[1]):
figure.sign = -figure.sign
figure.swap_points()
self.canvas.after(1, self.move_figures)
def move_for_thread(self, figure):
while True:
self.canvas.move(figure.id, figure.dir[0] * 0.1 * figure.sign, figure.dir[1] * 0.1 * figure.sign)
if figure.has_arrived(self.canvas.coords(figure.id)[0], self.canvas.coords(figure.id)[1]):
figure.sign = -figure.sign
figure.swap_points()
def delete_shadow_line(self):
if self.bg_line is not None:
self.canvas.delete(self.bg_line)
def on_mouse(self, event):
if self.is_drawing:
self.delete_shadow_line()
self.bg_line = self.canvas.create_line(self.start_point[0], self.start_point[1], event.x, event.y)
def callback(self, event):
if not self.is_drawing:
self.start_point = [event.x, event.y]
self.is_drawing = True
else:
self.is_drawing = False
self.bg_line = None
self.end_point = [event.x, event.y]
fig = self.canvas.create_oval(self.start_point[0] - self.diameter // 2,
self.start_point[1] - self.diameter // 2,
self.start_point[0] + self.diameter // 2,
self.start_point[1] + self.diameter // 2,
fill=self.figure_color, outline='')
# self.figures.append(
# Trajectory(self.start_point[0], self.start_point[1], self.end_point[0], self.end_point[1], fig,
# self.diameter))
# self.move_figures()
traj = Trajectory(self.start_point[0], self.start_point[1], self.end_point[0], self.end_point[1], fig, self.diameter)
t = threading.Thread(target=self.move_for_thread, args=(traj,))
t.start()
if __name__ == '__main__':
print('a')
app = App()
app.mainloop()
Using threads is adding more complexity than necessary. The code that uses after is easily capable of doing the animation.
However, you have a critical flaw. You're calling move_figures() every time you draw a line, and each time you call it you are starting another animation loop. Since the animation loop moves everything, the first object gets moved 1000 times per second. When you add two elements, each element is moving 1000 times per second twice. Three elements, it's moving 1000 times per second three times and so on.
So, start by removing the threading code. Then, call move_figures() exactly once, and within it, you should not be calling it with after(1, ...) since that's attempting to animate it 1000 times per second. Instead, you can reduce the load by 90% by using after(10, ...) or some other number bigger than 1.
You can call the function exactly once by calling it from App.__init__ rather than in App.callback.
I am trying to model a simple solar system in Tkinter using circles and moving them around in canvas. However, I am stuck trying to find a way to animate them. I looked around and found the movefunction coupled with after to create an animation loop. I tried fidgeting with the parameters to vary the y offset and create movement in a curved path, but I failed while trying to do this recursively or with a while loop. Here is the code I have so far:
import tkinter
class celestial:
def __init__(self, x0, y0, x1, y1):
self.x0 = x0
self.y0 = y0
self.x1 = x1
self.y1 = y1
sol_obj = celestial(200, 250, 250, 200)
sx0 = getattr(sol_obj, 'x0')
sy0 = getattr(sol_obj, 'y0')
sx1 = getattr(sol_obj, 'x1')
sy1 = getattr(sol_obj, 'y1')
coord_sol = sx0, sy0, sx1, sy1
top = tkinter.Tk()
c = tkinter.Canvas(top, bg='black', height=500, width=500)
c.pack()
sol = c.create_oval(coord_sol, fill='black', outline='white')
top.mainloop()
Here's something that shows one way to do what you want using the tkinter after method to update both the position of the object and the associated canvas oval object. It uses a generator function to compute coordinates along a circular path representing the orbit of one of the Celestial instances (named planet_obj1).
import math
try:
import tkinter as tk
except ImportError:
import Tkinter as tk # Python 2
DELAY = 100
CIRCULAR_PATH_INCR = 10
sin = lambda degs: math.sin(math.radians(degs))
cos = lambda degs: math.cos(math.radians(degs))
class Celestial(object):
# Constants
COS_0, COS_180 = cos(0), cos(180)
SIN_90, SIN_270 = sin(90), sin(270)
def __init__(self, x, y, radius):
self.x, self.y = x, y
self.radius = radius
def bounds(self):
""" Return coords of rectangle surrounding circlular object. """
return (self.x + self.radius*self.COS_0, self.y + self.radius*self.SIN_270,
self.x + self.radius*self.COS_180, self.y + self.radius*self.SIN_90)
def circular_path(x, y, radius, delta_ang, start_ang=0):
""" Endlessly generate coords of a circular path every delta angle degrees. """
ang = start_ang % 360
while True:
yield x + radius*cos(ang), y + radius*sin(ang)
ang = (ang+delta_ang) % 360
def update_position(canvas, id, celestial_obj, path_iter):
celestial_obj.x, celestial_obj.y = next(path_iter) # iterate path and set new position
# update the position of the corresponding canvas obj
x0, y0, x1, y1 = canvas.coords(id) # coordinates of canvas oval object
oldx, oldy = (x0+x1) // 2, (y0+y1) // 2 # current center point
dx, dy = celestial_obj.x - oldx, celestial_obj.y - oldy # amount of movement
canvas.move(id, dx, dy) # move canvas oval object that much
# repeat after delay
canvas.after(DELAY, update_position, canvas, id, celestial_obj, path_iter)
top = tk.Tk()
top.title('Circular Path')
canvas = tk.Canvas(top, bg='black', height=500, width=500)
canvas.pack()
sol_obj = Celestial(250, 250, 25)
planet_obj1 = Celestial(250+100, 250, 15)
sol = canvas.create_oval(sol_obj.bounds(), fill='yellow', width=0)
planet1 = canvas.create_oval(planet_obj1.bounds(), fill='blue', width=0)
orbital_radius = math.hypot(sol_obj.x - planet_obj1.x, sol_obj.y - planet_obj1.y)
path_iter = circular_path(sol_obj.x, sol_obj.y, orbital_radius, CIRCULAR_PATH_INCR)
next(path_iter) # prime generator
top.after(DELAY, update_position, canvas, planet1, planet_obj1, path_iter)
top.mainloop()
Here's what it looks like running:
I am trying to model a simple solar system in Tkinter using circles and moving them around in canvas. However, I am stuck trying to find a way to animate them. I looked around and found the movefunction coupled with after to create an animation loop. I tried fidgeting with the parameters to vary the y offset and create movement in a curved path, but I failed while trying to do this recursively or with a while loop. Here is the code I have so far:
import tkinter
class celestial:
def __init__(self, x0, y0, x1, y1):
self.x0 = x0
self.y0 = y0
self.x1 = x1
self.y1 = y1
sol_obj = celestial(200, 250, 250, 200)
sx0 = getattr(sol_obj, 'x0')
sy0 = getattr(sol_obj, 'y0')
sx1 = getattr(sol_obj, 'x1')
sy1 = getattr(sol_obj, 'y1')
coord_sol = sx0, sy0, sx1, sy1
top = tkinter.Tk()
c = tkinter.Canvas(top, bg='black', height=500, width=500)
c.pack()
sol = c.create_oval(coord_sol, fill='black', outline='white')
top.mainloop()
Here's something that shows one way to do what you want using the tkinter after method to update both the position of the object and the associated canvas oval object. It uses a generator function to compute coordinates along a circular path representing the orbit of one of the Celestial instances (named planet_obj1).
import math
try:
import tkinter as tk
except ImportError:
import Tkinter as tk # Python 2
DELAY = 100
CIRCULAR_PATH_INCR = 10
sin = lambda degs: math.sin(math.radians(degs))
cos = lambda degs: math.cos(math.radians(degs))
class Celestial(object):
# Constants
COS_0, COS_180 = cos(0), cos(180)
SIN_90, SIN_270 = sin(90), sin(270)
def __init__(self, x, y, radius):
self.x, self.y = x, y
self.radius = radius
def bounds(self):
""" Return coords of rectangle surrounding circlular object. """
return (self.x + self.radius*self.COS_0, self.y + self.radius*self.SIN_270,
self.x + self.radius*self.COS_180, self.y + self.radius*self.SIN_90)
def circular_path(x, y, radius, delta_ang, start_ang=0):
""" Endlessly generate coords of a circular path every delta angle degrees. """
ang = start_ang % 360
while True:
yield x + radius*cos(ang), y + radius*sin(ang)
ang = (ang+delta_ang) % 360
def update_position(canvas, id, celestial_obj, path_iter):
celestial_obj.x, celestial_obj.y = next(path_iter) # iterate path and set new position
# update the position of the corresponding canvas obj
x0, y0, x1, y1 = canvas.coords(id) # coordinates of canvas oval object
oldx, oldy = (x0+x1) // 2, (y0+y1) // 2 # current center point
dx, dy = celestial_obj.x - oldx, celestial_obj.y - oldy # amount of movement
canvas.move(id, dx, dy) # move canvas oval object that much
# repeat after delay
canvas.after(DELAY, update_position, canvas, id, celestial_obj, path_iter)
top = tk.Tk()
top.title('Circular Path')
canvas = tk.Canvas(top, bg='black', height=500, width=500)
canvas.pack()
sol_obj = Celestial(250, 250, 25)
planet_obj1 = Celestial(250+100, 250, 15)
sol = canvas.create_oval(sol_obj.bounds(), fill='yellow', width=0)
planet1 = canvas.create_oval(planet_obj1.bounds(), fill='blue', width=0)
orbital_radius = math.hypot(sol_obj.x - planet_obj1.x, sol_obj.y - planet_obj1.y)
path_iter = circular_path(sol_obj.x, sol_obj.y, orbital_radius, CIRCULAR_PATH_INCR)
next(path_iter) # prime generator
top.after(DELAY, update_position, canvas, planet1, planet_obj1, path_iter)
top.mainloop()
Here's what it looks like running:
My problem is very simple: I have two oval which centers are (x0, y0) and (x1, y1).
If I wanted to draw a line between them, I would simply do
create_line(x0, y0, x1, y1).
But I want to draw an arc between them. I'm struggling with the maths here. Here is the situation:
I have these two centers: they must be part of the ellipse
There is an infinity of ellipses going through these two points, but with tkinter, we can only draw horizontal ellipses. (right ?)
I need:
the top-left and lower-right coordinates of the rectangle that contains the ellipse
the start angle and the extent of the arc
I'm also thinking that maybe drawing an arc is the wrong way to go ? I could do something equivalent with a line, for which I specify a lot of points on that arc (even though it wouldn't be an actual arc)
Edit to answer Blindman:
With tkinter, you can define an arc like so:
http://infohost.nmt.edu/tcc/help/pubs/tkinter/web/create_oval.html
We don't define the ellipse per se, but only the top-left and lower-right corners coordinates of the rectangle that contains the ellipse.
How the two ovals relate to each other: they don't. You just have two oval at a random position on the canvas, and you want an arc between them.
I want the arc joining the two centers of these ovals.
Finally, here is an idea of what I want, in case the two oval have the same y coordinates:
What I want is exactly this, for any position.
And the tkinter code:
import tkinter as tk
class SampleApp(tk.Tk):
def __init__(self, *args, **kwargs):
tk.Tk.__init__(self, *args, **kwargs)
self.canvas = tk.Canvas(width=400, height=400)
self.canvas.pack(fill="both", expand=True)
self._create_token((100, 100), "white")
self._create_token((200, 100), "pink")
self.canvas.create_arc(100, 100 + 10, 200, 100 - 10, extent=180, style=tk.ARC)
def _create_token(self, coord, color):
'''Create a token at the given coordinate in the given color'''
(x,y) = coord
self.canvas.create_oval(x-5, y-5, x+5, y+5,
outline=color, fill=color, tags="token")
if __name__ == "__main__":
app = SampleApp()
app.mainloop()
There is a start option for creating the arc, which define where to start the drawing in giving angle. Using this and some math you can use the create_arc-method to draw an arc for any position:
import Tkinter as tk
class SampleApp(tk.Tk):
def __init__(self, *args, **kwargs):
tk.Tk.__init__(self, *args, **kwargs)
self.canvas = tk.Canvas(width=400, height=400)
self.canvas.pack(fill="both", expand=True)
self._create_token((100, 100), "white")
self._create_token((200, 300), "pink")
self._create_arc((100,100), (200, 300))
def _create_token(self, coord, color):
'''Create a token at the given coordinate in the given color'''
(x,y) = coord
self.canvas.create_oval(x-5, y-5, x+5, y+5,
outline=color, fill=color, tags="token")
def _create_arc(self, p0, p1):
extend_x = (self._distance(p0,p1) -(p1[0]-p0[0]))/2 # extend x boundary
extend_y = (self._distance(p0,p1) -(p1[1]-p0[1]))/2 # extend y boundary
startAngle = math.atan2(p0[0] - p1[0], p0[1] - p1[1]) *180 / math.pi # calculate starting angle
self.canvas.create_arc(p0[0]-extend_x, p0[1]-extend_y ,
p1[0]+extend_x, p1[1]+extend_y,
extent=180, start=90+startAngle, style=tk.ARC)
'''use this rectangle for visualisation'''
#self.canvas.create_rectangle(p0[0]-extend_x, p0[1]-extend_y,
# p1[0]+extend_x, p1[1]+extend_y)
def _distance(self, p0, p1):
'''calculate distance between 2 points'''
return sqrt((p0[0] - p1[0])**2 + (p0[1] - p1[1])**2)
if __name__ == "__main__":
app = SampleApp()
app.mainloop()
Actually, I will answer my own question, since I found a better way to do it.
VRage's answer is fine but it's hard to adapt in case you want the arc closer to the line between the two points.
The right way to do it is to use a line with a third point on the perpendicular bissector of the segment, combined with the "smooth" option.
Here is an example with exactly what I wanted:
import tkinter as tk
import math
class SampleApp(tk.Tk):
def __init__(self, *args, **kwargs):
tk.Tk.__init__(self, *args, **kwargs)
self.canvas = tk.Canvas(width=400, height=400)
self.canvas.pack(fill="both", expand=True)
def _create_token(self, coord, color):
'''Create a token at the given coordinate in the given color'''
(x,y) = coord
self.canvas.create_oval(x-5, y-5, x+5, y+5,
outline=color, fill=color, tags="token")
def create(self, xA, yA, xB, yB, d=10):
self._create_token((xA, yA), "white")
self._create_token((xB, yB), "pink")
t = math.atan2(yB - yA, xB - xA)
xC = (xA + xB)/2 + d * math.sin(t)
yC = (yA + yB)/2 - d * math.cos(t)
xD = (xA + xB)/2 - d * math.sin(t)
yD = (yA + yB)/2 + d * math.cos(t)
self.canvas.create_line((xA, yA), (xC, yC), (xB, yB), smooth=True)
self.canvas.create_line((xA, yA), (xD, yD), (xB, yB), smooth=True, fill="red")
if __name__ == "__main__":
app = SampleApp()
app.create(100, 100, 300, 250)
app.mainloop()
I am generating diagrams in Turtle, and as part of my program, I identify certain coordinates from my diagrams. I would like to be able to hide the complete turtle window, since I only care about the coordinates, is that possible?
Edit:
QUESTION 2:
This isn't really an answer, but a few other questions.
I got my program working to some extent, if you run it in IDLE and type "l" it will give you the list with the coordinates.
import Tkinter
import turtle
from turtle import rt, lt, fd # Right, Left, Forward
size = 10
root = Tkinter.Tk()
root.withdraw()
c = Tkinter.Canvas(master = root)
t = turtle.RawTurtle(c)
t.speed("Fastest")
# List entire coordinates
l = []
def findAndStoreCoords():
x = t.xcor()
y = t.ycor()
x = round(x, 0) # Round x to the nearest integer
y = round(y, 0) # Round y to the nearest integer
# Integrate coordinates into sub-list
l.append([x, y])
def hilbert(level, angle):
if level == 0:
return
t.rt(angle)
hilbert(level - 1, -angle)
t.fd(size)
findAndStoreCoords()
t.lt(angle)
hilbert(level - 1, angle)
t.fd(size)
findAndStoreCoords()
hilbert(level - 1, angle)
t.lt(angle)
t.fd(size)
findAndStoreCoords()
hilbert(level - 1, -angle)
t.rt(angle)
The problem is that Turtle is so SLOW! Is there any package that is just like Turtle but can do commands much faster?
I reimplemented the turtle class as suggested by thirtyseven. It is consistent with the api. (i.e. when you turn right in this class, it is the same as turning right in turtle.
This does not implement all the methods in the api, only common ones. (And the ones you used).
However, it's short and fairly straightforward to extend. Also, it keeps track of all of the points it has been to. It does this by adding an entry to pointsVisited every time you call forward, backward, or setpos (or any of the aliases for those functions).
import math
class UndrawnTurtle():
def __init__(self):
self.x, self.y, self.angle = 0.0, 0.0, 0.0
self.pointsVisited = []
self._visit()
def position(self):
return self.x, self.y
def xcor(self):
return self.x
def ycor(self):
return self.y
def forward(self, distance):
angle_radians = math.radians(self.angle)
self.x += math.cos(angle_radians) * distance
self.y += math.sin(angle_radians) * distance
self._visit()
def backward(self, distance):
self.forward(-distance)
def right(self, angle):
self.angle -= angle
def left(self, angle):
self.angle += angle
def setpos(self, x, y = None):
"""Can be passed either a tuple or two numbers."""
if y == None:
self.x = x[0]
self.y = y[1]
else:
self.x = x
self.y = y
self._visit()
def _visit(self):
"""Add point to the list of points gone to by the turtle."""
self.pointsVisited.append(self.position())
# Now for some aliases. Everything that's implemented in this class
# should be aliased the same way as the actual api.
fd = forward
bk = backward
back = backward
rt = right
lt = left
setposition = setpos
goto = setpos
pos = position
ut = UndrawnTurtle()
Yes, this is possible. The simplest way is to instantiate a root Tkinter window, withdraw it, and then use it as the master window for a RawTurtle's Canvas instance.
Example:
import Tkinter
import turtle
root=Tkinter.Tk()
root.withdraw()
c=Tkinter.Canvas(master=root)
t=turtle.RawTurtle(c)
t.fd(5)
print t.xcor() # outputs 5.0
Unfortunately, this still initiates the graphics system, but no window will appear.
The problem is that Turtle is so SLOW! Is there any package that is
just like Turtle but can do commands much faster?
Yes, turtle can. If we add a TurtleScreen to the tkinter implementation, and use it's tracer() functionality, we can speed things up more than turtle's speed() method. And we can simplify the code greatly by tossing the customizations and simply use turtle's own begin_poly(), end_poly() and get_poly() methods:
from tkinter import Tk, Canvas
from turtle import TurtleScreen, RawTurtle
SIZE = 10
def hilbert(level, angle):
if level == 0:
return
turtle.right(angle)
hilbert(level - 1, -angle)
turtle.forward(SIZE)
turtle.left(angle)
hilbert(level - 1, angle)
turtle.forward(SIZE)
hilbert(level - 1, angle)
turtle.left(angle)
turtle.forward(SIZE)
hilbert(level - 1, -angle)
turtle.right(angle)
root = Tk()
root.withdraw()
canvas = Canvas(master=root)
screen = TurtleScreen(canvas)
screen.tracer(False) # turn off turtle animation
turtle = RawTurtle(screen)
turtle.begin_poly() # start tracking movements
hilbert(5, 90)
turtle.end_poly() # end tracking movements
print(turtle.get_poly())
This prints all the points in a level 5 Hilbert curve in about 1/3 of a second on my system. Your posted code toke nearly 9 seconds to output a level 4.