I'm trying to get the intersection of a blue and green QPolygons, and the % that this makes up of the blue QPolygon (see image below).
I've created the polygons as follows:
import PyQt5
from PyQt5 import QtWidgets,QtGui,QtCore
from PyQt5.QtWidgets import *
from PyQt5.QtGui import *
from PyQt5.QtCore import *
import sys, math
class MyWidget(QtWidgets.QWidget):
def __init__(self, parent=None):
QtWidgets.QWidget.__init__(self, parent)
self.pen1 = QtGui.QPen(QtGui.QColor(20,0,250)) # set lineColor
self.pen = QtGui.QPen(QtGui.QColor(0,0,0)) # set lineColor
self.pen2 = QtGui.QPen(QtGui.QColor(0,250,0)) # set lineColor
self.pen.setWidth(3) # set lineWidth
self.brush = QtGui.QBrush(QtGui.QColor(255,255,255,10)) # set fillColor
coords = [PyQt5.QtCore.QPointF(640.0, 334.0), PyQt5.QtCore.QPointF(626.0, 462.0), PyQt5.QtCore.QPointF(782.0, 509.0), PyQt5.QtCore.QPointF(807.0, 373.0), PyQt5.QtCore.QPointF(807.0, 333.0)]
greenpolygoncoords = [PyQt5.QtCore.QPointF(698.0, 373.0), PyQt5.QtCore.QPointF(690.0, 433.0), PyQt5.QtCore.QPointF(757.0, 376.0), PyQt5.QtCore.QPointF(713.0, 351.0), PyQt5.QtCore.QPointF(713.0, 351.0), PyQt5.QtCore.QPointF(698.0, 373.0)]
self.bluepolygon = QPolygonF(coords)
self.greenpolygon =QPolygonF(greenpolygoncoords)
self.intersection = self.bluepolygon.intersected(self.greenpolygon)
print("overlap = "+str(self.intersection.size()/self.bluepolygon.size()))
def paintEvent(self, event):
painter = QtGui.QPainter(self)
painter.setPen(self.pen1)
painter.setBrush(self.brush)
painter.drawPolygon(self.bluepolygon) #bluepolygon = Blue
painter.setPen(self.pen)
painter.drawPolygon(self.intersection) #Intersection = black
painter.setPen(self.pen2)
painter.drawPolygon(self.greenpolygon) # greenpolygon = Green
app = QtWidgets.QApplication(sys.argv)
widget = MyWidget()
widget.show()
sys.exit(app.exec_())
However, when I try to calculate the overlap between the two polygons, as a percentage of the blue polygon, I get a wrong answer (it says that the overlap is equal to 1, however, the green polygon definately does not make up 100% of the blue polygon).
overlap = self.intersection.size()/self.bluepolygon.size()
When looking at the sizes calculated by QPolygon.size(), it becomes apparent that the QPolygon.size() returns an incorrect size of the polygon.
print("size of blue polygon = "+str(self.bluepolygon.size()))
print("size of greenpolygon = "+str(self.greenpolygon.size()))
print("size of intersection = "+str(self.intersection.size()))
returns:
size of blue polygon = 5
size of greenpolygon = 6
size of intersection = 5
Apparently for some reason for PyQt5, the blue polygon is smaller than the green polygon. How come?
What am I doing wrong?
The size() method does not return the area of the polygon but the number of sides or vertices. If you want to calculate the area then you must implement it using the standard method:
def calculate_area(qpolygon):
area = 0
for i in range(qpolygon.size()):
p1 = qpolygon[i]
p2 = qpolygon[(i + 1) % qpolygon.size()]
d = p1.x() * p2.y() - p2.x() * p1.y()
area += d
return abs(area) / 2
And then you evaluate what you want:
print(
"overlap = "
+ str(calculate_area(self.intersection) / calculate_area(self.bluepolygon))
)
Output:
overlap = 0.0962288941619438
Related
I have a nested layout 4-levels of nesting
as in the attached photo
top layout is
MainHorLayout
|
|--QVBoxLayout_______________ Hosts CheckBoxes
|--AllfigVlayout_____________ Hosts figures
|-TopHlayoutfig_0_1_______Hosts profile_0,Profile_1 charts, downside labels
| |
| |-- TopVlayoutFig_0_Lbl (loaded with chart[0], chart[0].belowlabel)
| |
| |-- TopVlayoutFig_1_Lbl (loaded with chart[1], chart[1].belowlabel)
|
|-BotHlayoutfig_2_3_______same as TopHlayoutfig_0_1 but for bottom fig 2,3
|
|-- TopVlayoutFig_2_Lbl (loaded with chart[2], chart[2].belowlabel)
|
|-- TopVlayoutFig_3_Lbl (loaded with chart[2], chart[2].belowlabel)
I'm using check box to toggle the charts[i] visibale/ unvisible
start up behaviour is shown in phot 1
No problem with Charts[0] and charts2 as in photo 2
The problem when i'm trying to hid charts1 it hides charts[0] and 1
also trying to hide charts3 it hides both 2 and 3 please check photo 3
when i trying to show them again the dimensions of charts is not equall and non symetrical please check photo 4
as it shows when program starts
from functools import partial
from scipy.interpolate import make_interp_spline, BSpline
import sys
import matplotlib.pyplot as plt
import numpy as np
#from PyQt5.QtWidgets import QWidget
import matplotlib
matplotlib.use('QtAgg')
from PyQt6 import QtCore
from PyQt6.QtWidgets import QWidget,QApplication , QLabel, QSizePolicy, QFrame, QCheckBox, QVBoxLayout, QHBoxLayout
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from crosshairplt import CrossHairCursor
import mouse
import csv
NUMBERofProfiles = 4
class BelowFigLabel(QLabel):
def __init__(self):
super().__init__()
sizePolicy = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
#sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
self.setMaximumHeight(60)
#self.setMaximumHeight(self.height()/2)
#sizePolicy.setHeightForWidth(self.sizePolicy().hasHeightForWidth())
self.setSizePolicy(sizePolicy)
#self.setTextFormat(QtCore.Qt.TextFormat.RichText)
self.setScaledContents(True)
#self.label_1.setStyleSheet("font-family: Verdana,sans-serif;font: 87 10pt \"Arial Black\";\n""background-color: rgb(30,231,235);")#;font-size: 12px;height: 28px;width: 80px
self.setStyleSheet("font-family: Verdana,sans-serif; font: 87 12pt ;font-weight:Bold;background-color: #007EA7;color:#E9D2F4")
self.setFrameShape(QFrame.Shape.Box)
self.setFrameShadow(QFrame.Shadow.Sunken)
self.setLineWidth(2)
###############################
class Canvas(FigureCanvas):
def __init__(self, parent,Horizontal,Vertical,figname,subscribt):
self.parentx = parent
self.t = Horizontal
self.s = Vertical
self.figname = figname +f"_{subscribt}"
self.fig, self.ax = plt.subplots()
super().__init__(self.fig)
self.setParent(parent)# parent must be a QWidget
self.geo = self.parentx.geometry() # used to calculate the fig location
self.x = (self.t[-1] - self.t[0])/2 #initial zome distance axis rang/2
self.belowfiglbl = BelowFigLabel()
self.colSize = self.s.shape[0] # get column size
self.slic = [self.s[i][0] for i in range(self.colSize)]
#self.cursor = CrossHairCursor(self.ax,self.t,self.slic, self.belowfiglbl)
self.ax.plot(self.t, self.s)
self.ax.set(xlabel='time (s)', ylabel='Current (A))', title=self.figname)
self.ax.grid()
self.ax.grid(True,'both','x',color='r', linestyle='-' , linewidth=1)
self.ax.grid(True,'both','y',color='b', linestyle='--', linewidth=1)
self.ax.grid()
ticks = []
self.fig.canvas.mpl_connect('scroll_event', self.on_mouse_scroll)
#self.fig.canvas.mpl_connect('motion_notify_event', self.cursor.on_mouse_move)
#self.fig.canvas.mpl_connect('button_press_event', self.cursor.on_mouse_click)
#self.fig.canvas.mpl_connect('button_release_event', self.cursor.on_mouse_click)
"""
Matplotlib Script
"""
def on_mouse_scroll(self,event):
#self.geo = self.parentx.geometry()
#print(self.geo)
self.event = event
if self.event.inaxes:
if self.event.button == 'up':
max_x = self.event.xdata + self.x
min_x = self.event.xdata - self.x
#print("P= %0.3f min = %0.3f max = %0.3f x= %0.3f" %(self.event.xdata, min_x,max_x, self.x))
if max_x > (min_x+.002): # ADD .002 MARGIN COZ when both limits get equall the widget behalves in WRONG WAY
if min_x > self.t[0] and max_x < self.t[-1]:
self.ax.set_xlim(min_x,max_x)
self.x /=2
#print("in bound")
elif min_x <= self.t[0]:
#print(" -------bound")
self.ax.set_xlim(self.t[0],max_x)
self.x /=2
else:
#print(" ++++++++++bound")
self.ax.set_xlim(min_x,self.t[-1])
self.x /=2
elif self.event.button == 'down':
x_0 = self.ax.get_xlim()
#y_0 = self.ax.get_ylim()
min_x = x_0[0]-self.x
max_x = x_0[1]+self.x
if min_x >= self.t[0] and max_x <= self.t[-1]:
self.ax.set_xlim(min_x,max_x)
self.x *=2
elif min_x < self.t[0] and max_x <= self.t[-1]:
self.ax.set_xlim(self.t[0],max_x)
self.x *=2
elif min_x>= self.t[0] and max_x > self.t[-1]:
self.ax.set_xlim(min_x,self.t[-1])
self.x *=2
else:
self.ax.set_xlim(self.t[0],self.t[-1])
#self.fig.canvas.draw()
"""
Get new Transformed co-ordinates after Canvas redraw to have updated xdata and ydata transformed
"""
DataTransformedXtoCanvas,DataTransformedYtoCanvas = self.ax.transData.transform((self.event.xdata,self.event.ydata))
"""
- The mouse object co-ordinates are the physical display
- The event object Uses the Data Co-ordinates for event.xdata,event.ydata
- When we use the transData it tranform the data Co-ordinates into fig Co-ordinates
- The Fig's are hosted in GridLayout so each figure has it on position relative to parent widget window
- The parent window has its own relative position to physical dispaly
---- so to keep the mouse pointer pointing to the DATApOINT under zoom in/out
1- we first Transform Data Coordinates into fig coordinates using .transData.transform
2- add to the coordinates in (1) the canvas.geometry()'s topleft corner position relative to parent
self.geometry().left(), self.geometry().top()
3- add the parent TopLeft corner position relative to PHYSICAL DISPLAY
"""
CavasTopCornertoParent = self.geometry().top()
CavasLefCornertoParent = self.geometry().left()
ParentTopCornertoScreen = self.parentx.geometry().top()
ParentLeftCornertoScreen = self.parentx.geometry().left()
PointerPositionX = (DataTransformedXtoCanvas + CavasLefCornertoParent + ParentLeftCornertoScreen)
PointerPositionY = (DataTransformedYtoCanvas + CavasTopCornertoParent + ParentTopCornertoScreen)
mouse.move(PointerPositionX , PointerPositionY)
def on_mouse_click(self,event):
print(event.xdata,event.ydata)
print("geo = ", self.parentx.geometry())#fig.canvas.geometry())
inverted = self.ax.transData.inverted().transform((event.x,event.y))
print(f"inverted ={inverted}, screen xy ={(event.x,event.y)}")
class AppDemo(QWidget):
def __init__(self):
super().__init__()
self.resize(1600, 800)
#self.setStyleSheet("font: 75 10pt \"Monospac821 BT\";""background-color: rgb(120,190,220);")
self.setStyleSheet("font: 75 10pt Monospac821 BT;background-color: #003459")#rgb(120,190,220)")
self.setWindowTitle("Current Oscilography")
self.s=[]
self.Ismoth=[]
for i in range(NUMBERofProfiles):
with open(f"E:/OneDrive/011_APROJECTS/digital relay/code/GitHub/Python Application/SourceCode/MODBUSfolder/loadprofiles/tstdata/LP_{i}.csv", mode='r', newline='') as profile:
a = csv.reader(profile)
x = 0
CurrentTimeSamples = [] # list holding 5 col 4 for current and 1st is for time series
for row in a:
try:
xx =[float(i) for i in row ]
CurrentTimeSamples.append(xx)
except:
CurrentTimeSamples.append(row)
#CurrentTimeSamples is 5x801 list
CurrentTimeSamples = CurrentTimeSamples[1:] # Remove Header Line
CurrentOnly = [CurrentTimeSamples[i][1:] for i in range(len(CurrentTimeSamples))] # remove 1st Col which is time series
#Isolate the time series
timex = np.array([CurrentTimeSamples[i][0] for i in range(len(CurrentTimeSamples))])
#self.s.append(CurrentOnly)
#self.s[i]=np.array(self.s[i])
# convert to Numpy
CurrentOnly = np.array(CurrentOnly)
# evenly spaced 3200 points between Xmin and X
self.time = np.linspace(timex.min(), timex.max(), 3200)
# order 3 " Quadraic" Interpolation Equation
spl = make_interp_spline(timex, CurrentOnly, k=3)
# Application of the quadratic interpolation f
self.Ismoth.append(spl(self.time))
self.charts = []
for i in range(NUMBERofProfiles):
self.charts.append(Canvas(self,self.time,self.Ismoth[i],"Profile", i))
self.MainHorLayout = QHBoxLayout(self)
#self.MainHorLayout.setSpacing(0)
self.TopHlayoutfig_0_1 = QHBoxLayout()
#self.TopHlayoutfig_0_1.setSpacing(0)
self.TopHlayoutfig_0_1.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop)#QtCore.Qt.AlignmentFlag.AlignLeft|
self.TopVlayoutFig_0_Lbl =QVBoxLayout()
self.TopVlayoutFig_0_Lbl.addWidget(self.charts[0])
self.TopVlayoutFig_0_Lbl.addWidget(self.charts[0].belowfiglbl)
self.TopVlayoutFig_1_Lbl =QVBoxLayout()
self.TopVlayoutFig_1_Lbl.addWidget(self.charts[1])
self.TopVlayoutFig_1_Lbl.addWidget(self.charts[1].belowfiglbl)
self.TopHlayoutfig_0_1.addLayout(self.TopVlayoutFig_0_Lbl)
self.TopHlayoutfig_0_1.addLayout(self.TopVlayoutFig_1_Lbl)
self.BotHlayoutfig_2_3 = QHBoxLayout()
self.BotHlayoutfig_2_3.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop)
self.BotVlayoutFig_2_Lbl =QVBoxLayout()
self.BotVlayoutFig_2_Lbl.addWidget(self.charts[2])
self.BotVlayoutFig_2_Lbl.addWidget(self.charts[2].belowfiglbl)
self.BotVlayoutFig_3_Lbl =QVBoxLayout()
self.BotVlayoutFig_3_Lbl.addWidget(self.charts[3])
self.BotVlayoutFig_3_Lbl.addWidget(self.charts[3].belowfiglbl)
self.BotHlayoutfig_2_3.addLayout(self.BotVlayoutFig_2_Lbl)
self.BotHlayoutfig_2_3.addLayout(self.BotVlayoutFig_3_Lbl)
self.AllfigVlayout = QVBoxLayout()
self.AllfigVlayout.addLayout(self.TopHlayoutfig_0_1)
self.AllfigVlayout.addLayout(self.BotHlayoutfig_2_3)
#self.AllfigVlayout.setSpacing(0)
self.MainHorLayout.addLayout(self.AllfigVlayout)
SideBarVLayout_main = QVBoxLayout()
LeftSidbarLabel = QLabel()
ChBoxesFrame = QFrame()
ChBoxesFrame.setStyleSheet("font-family: Verdana,sans-serif; font: 40 12pt ;font-weight:Bold;background-color: #30638E;color:#ffd166")
ChBoxesFrame.setContentsMargins(5,1,5,5)
ChBoxesFrame.setFrameShape(QFrame.Shape.Box)
ChBoxesFrame.setFrameShadow(QFrame.Shadow.Sunken)
ChBoxesFrame.setLineWidth(3)
ChBoxesFrame.setMaximumHeight(220)
ChBoxesFrame_Vlaouy = QVBoxLayout(ChBoxesFrame)
SelectActvProfilLabel = QLabel("SeLect Profiles", ChBoxesFrame)
SelectActvProfilLabel.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop|QtCore.Qt.AlignmentFlag.AlignLeft)
SelectActvProfilLabel.setMaximumHeight(20)
ChBoxesFrame_Vlaouy.addWidget(SelectActvProfilLabel)
cc = QCheckBox()
self.ChkBox =[]
for i in range(NUMBERofProfiles):
self.ChkBox.append(QCheckBox(f"Profile_{i}",ChBoxesFrame))
self.ChkBox[i].setChecked(True)
self.ChkBox[i].clicked.connect(partial(self.chkBox_cliked,i))
ChBoxesFrame_Vlaouy.addWidget(self.ChkBox[i])
SideBarVLayout_main.addWidget(ChBoxesFrame)
LeftSidbarLabel.setMaximumWidth(180)
LeftSidbarLabel.setStyleSheet("font-family: Verdana,sans-serif; font: 40 12pt ;font-weight:Bold;background-color: #30638E;color:#EDAE49")
LeftSidbarLabel.setContentsMargins(10,5,5,5)
LeftSidbarLabel.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop|QtCore.Qt.AlignmentFlag.AlignLeft)
LeftSidbarLabel.setFrameShape(QFrame.Shape.Box)
LeftSidbarLabel.setFrameShadow(QFrame.Shadow.Sunken)
LeftSidbarLabel.setLineWidth(3)
SideBarVLayout_main.addWidget(LeftSidbarLabel)
self.MainHorLayout.addLayout(SideBarVLayout_main, )
def chkBox_cliked(self,ckboxNum):
#for i in range(NUMBERofProfiles):
if self.ChkBox[ckboxNum].isChecked() == False:
self.charts[ckboxNum].belowfiglbl.setVisible(False)
#deleteLater()
self.charts[ckboxNum].setVisible(False)
else:
self.charts[ckboxNum].setVisible(True)# = Canvas(self,self.time,self.Ismoth[ckboxNum],"Profile",ckboxNum)
self.charts[ckboxNum].belowfiglbl.setVisible(True)
#self.MainHorLayout.update()
if __name__ == "__main__":
app = QApplication(sys.argv)
demo = AppDemo()
demo.show()#Maximized()
sys.exit(app.exec())
I am trying to create a custom checkbox. I am using paintEvent function to create my special checkbox.
It's design:
The result on Qt:
First of all, rounded should be added and the junction of the lines should be a smoother transition.
I need a more professional solution. Which is pretty looking.
Thanks!
Code:
import sys, os, time
from PySide6 import QtCore, QtWidgets, QtGui
from PySide6.QtWidgets import *
from PySide6.QtCore import *
from PySide6.QtGui import *
class ECheckBoxData(object):
Radius = 10
AnimationTime = 600 # ms
FontSize, FontSpacing = 16, 0
Color = {
"CORNER": QColor(239, 239, 239),
"BASE_BACKGROUND": QColor(255, 125, 51),
"BASE_FOREGROUND": QColor(255, 152, 91),
"BASE_HOVER_BACKGROUND" :QColor(255, 152, 91),
"BASE_HOVER_FOREGROUND": QColor(247, 247, 250),
}
TextElide = Qt.ElideMiddle
CheckWidth, CheckHeight = 128, 128
class ECheckBox(QCheckBox):
CheckBoxData = ECheckBoxData()
def __init__(self, CheckBoxData=ECheckBoxData()):
super(ECheckBox, self).__init__(None)
self.CheckBoxData = CheckBoxData
self.myfont = QFont("Times New Roman", 16, weight=QFont.Bold)
self.myfont.setWordSpacing(self.CheckBoxData.FontSpacing)
self.myfont.setStyleHint(QFont.Monospace)
self.myfontMetrics = QFontMetrics(self.myfont)
# font.setStyleHint(QFont.Times, QFont.PreferAntialias)
self.setFont(self.myfont)
self.setFixedHeight(self.CheckBoxData.CheckHeight+2)
self.setMinimumWidth(self.CheckBoxData.CheckWidth+8)
def paintEvent(self, event: QPaintEvent):
pt = QPainter(self)
pt.setRenderHints(QPainter.Antialiasing | QPainter.TextAntialiasing )
border = QPainterPath()
pt.setBrush(self.CheckBoxData.Color["BASE_BACKGROUND"])
pt.setPen(QPen(self.CheckBoxData.Color["CORNER"],5))
border.addRoundedRect(QRectF(2,2,self.CheckBoxData.CheckWidth-2, self.CheckBoxData.CheckHeight-2),self.CheckBoxData.Radius, self.CheckBoxData.Radius)
pt.drawPath(border)
pt.setClipPath(border)
pt.setPen(QPen(Qt.white,self.CheckBoxData.CheckWidth/10))
pt.setBrush(Qt.white)
path2 = QPainterPath()
arrow_width, arrow_height = self.width()/4, self.height()/ (66/8)
center_width, center_height = int(self.width()/2), int(self.height()/2)
#path2.moveTo((self.width() - arrow_width * 2) / 2, (center_height + 2))
#path2.lineTo(QPoint((self.width() - arrow_width) / 2 + 2, (center_height) + arrow_height + 1))
#path2.lineTo(QPoint((self.width()-arrow_width), (center_height)-arrow_height))
path2.addPolygon(QPolygonF([
QPoint((self.width()-arrow_width*2)/2, (center_height+2)), QPoint((self.width()-arrow_width)/2+2, (center_height)+arrow_height+1)
]))
path2.addPolygon(QPolygonF([QPoint((self.width()-arrow_width)/2+2, (center_height)+arrow_height+1), QPoint((self.width()-arrow_width-12), (center_height)-arrow_height)]))
pt.drawPath(path2)
if __name__ == "__main__":
app = QApplication(sys.argv)
wind = QMainWindow()
wind.setStyleSheet("QMainWindow{background-color:rgb(247,247,250)}")
wind.resize(221, 150)
wid = QWidget()
lay = QHBoxLayout(wid)
lay.setAlignment(Qt.AlignCenter)
Data = ECheckBoxData()
e = ECheckBox(Data)
e.setChecked(True)
lay.addWidget(e)
wind.setCentralWidget(wid)
wind.show()
sys.exit(app.exec())
In order to create a smooth and curved outline, you need to properly set the QPen cap and join style.
Using a polygon to draw the outline is obviously not a valid solution, as that outline will be drawn with the pen, but what you need is a path that will be painted with a thick pen and the preferred cap and join styles.
Also, in order to be able to draw a good icon at different sizes, you should not rely on fixed sizes (even if properly computed), but use the current size as a reference instead.
def paintEvent(self, event: QPaintEvent):
pt = QPainter(self)
pt.setRenderHints(QPainter.Antialiasing | QPainter.TextAntialiasing)
size = min(self.width(), self.height())
border = max(1, size / 32)
rect = QRectF(0, 0, size - border, size - border)
# move the square to the *exact* center using a QRectF based on the
# current widget; note: it is very important that you get the center
# using a QRectF, because the center of QRect is always in integer
# values, and will almost always be imprecise at small sizes
rect.moveCenter(QRectF(self.rect()).center())
borderPath = QPainterPath()
# with RelativeSize we can specify the radius as 30% of half the size
borderPath.addRoundedRect(rect, 30, 30, Qt.RelativeSize)
pt.setBrush(self.CheckBoxData.Color["BASE_BACKGROUND"])
pt.setPen(QPen(self.CheckBoxData.Color["CORNER"], border * 2.5))
pt.drawPath(borderPath)
pt.setPen(QPen(Qt.white, size * .125,
cap=Qt.RoundCap, join=Qt.RoundJoin))
arrow_path = QPainterPath()
arrow_path.moveTo(size * .25, size * .5)
arrow_path.lineTo(size * .40, size * .65)
arrow_path.lineTo(size * .7, size * .325)
pt.drawPath(arrow_path.translated(rect.topLeft()))
Using PySide2 or PyQt5, I want to make a table widget with header labels that are on a 45 degree angle, like in the image here.
I don't see anything like this in QtCreator (Designer) for the QTable widget. I can rotate a label using something like this:
class MyLabel(QtGui.QWidget):
def paintEvent(self, event):
painter = QtGui.QPainter(self)
painter.setPen(QtCore.Qt.black)
painter.translate(20, 100)
painter.rotate(-45)
painter.drawText(0, 0, "hellos")
painter.end()
But, there are several niggles. Ideally this would be a QLineEdit widget, I would need the widgets to 'play nice' so as not to overlap anything else, and I would like them to fill in above the table from the header. I'm looking for suggestions.
This is a very interesting topic, as Qt doesn't provide such a feature, but it can be implemented.
The following example is far from perfect, I'll list its main pros/cons.
Pros
it works ;-)
changing horizontal header labels automatically updates the header height
supports horizontal scrolling "over" the last item position (if the table view is smaller than its contents, the horizontal scrollbar allows to see the full header text)
it works :-D
Cons
sections are fixed
sections are not movable
QAbstractItemView.ScrollPerPixel is mandatory for the horizontal scroll mode in this implementation. Qt's ScrollPerItem mode is a bit complex, and has some issues if it's not overrided with huge care. This doesn't mean that it's not possible to use that mode, but it requires a lot of efforts, possibly by carefully reading and understanding the source code of both QTableView and QAbstractItemView. Long story short: ScrollPerItem works until you reach the maximum value of the horizontal scrollbar; at that point, the view will try to resize and adapt its viewport and scrollbar value/range, and the last header labels will be "cut out".
if all horizontal columns are visible (meaning that the items wouldn't require horizontal scrolling), the last horizontal headers are not completely shown, since the horizontal scroll bar is not required.
I think that it should be possible to support all header features (custom/stretchable section size, movable sections, item scroll, etc.), but it would require a very deep reimplementation process of both QTableView and QHeaderView methods.
Anyhow, that's the result I've got so far, which supports scrolling, painting, and basic mouse interaction (section highlight on click).
Example screenshot:
Scrolled (near the right edge) screenshot:
Table sized slightly after the right edge of the last horizontal column:
Example code
import sys
from math import sqrt, sin, acos, hypot, degrees, radians
from PyQt5 import QtCore, QtGui, QtWidgets
class AngledHeader(QtWidgets.QHeaderView):
borderPen = QtGui.QColor(0, 190, 255)
labelBrush = QtGui.QColor(255, 212, 0)
def __init__(self, parent=None):
QtWidgets.QHeaderView.__init__(self, QtCore.Qt.Horizontal, parent)
self.setSectionResizeMode(self.Fixed)
self.setDefaultSectionSize(sqrt((self.fontMetrics().height() + 4)** 2 *2))
self.setSectionsClickable(True)
self.setDefaultSectionSize(int(sqrt((self.fontMetrics().height() + 4)** 2 *2)))
self.setMaximumHeight(100)
# compute the ellipsis size according to the angle; remember that:
# 1. if the angle is not 45 degrees, you'll need to compute this value
# using trigonometric functions according to the angle;
# 2. we assume ellipsis is done with three period characters, so we can
# "half" its size as (usually) they're painted on the bottom line and
# they are large enough, allowing us to show as much as text is possible
self.fontEllipsisSize = int(hypot(*[self.fontMetrics().height()] * 2) * .5)
self.setSectionsClickable(True)
def sizeHint(self):
# compute the minimum height using the maximum header label "hypotenuse"'s
hint = QtWidgets.QHeaderView.sizeHint(self)
count = self.count()
if not count:
return hint
fm = self.fontMetrics()
width = minSize = self.defaultSectionSize()
# set the minimum width to ("hypotenuse" * sectionCount) + minimumHeight
# at least, ensuring minimal horizontal scroll bar interaction
hint.setWidth(width * count + self.minimumHeight())
maxDiag = maxWidth = maxHeight = 1
for s in range(count):
if self.isSectionHidden(s):
continue
# compute the diagonal of the text's bounding rect,
# shift its angle by 45° to get the minimum required
# height
rect = fm.boundingRect(
str(self.model().headerData(s, QtCore.Qt.Horizontal)) + ' ')
# avoid math domain errors for empty header labels
diag = max(1, hypot(rect.width(), rect.height()))
if diag > maxDiag:
maxDiag = diag
maxWidth = max(1, rect.width())
maxHeight = max(1, rect.height())
# get the angle of the largest boundingRect using the "Law of cosines":
# https://en.wikipedia.org/wiki/Law_of_cosines
angle = degrees(acos(
(maxDiag ** 2 + maxWidth ** 2 - maxHeight ** 2) /
(2. * maxDiag * maxWidth)
))
# compute the minimum required height using the angle found above
minSize = max(minSize, sin(radians(angle + 45)) * maxDiag)
hint.setHeight(min(self.maximumHeight(), minSize))
return hint
def mousePressEvent(self, event):
width = self.defaultSectionSize()
start = self.sectionViewportPosition(0)
rect = QtCore.QRect(0, 0, width, -self.height())
transform = QtGui.QTransform().translate(0, self.height()).shear(-1, 0)
for s in range(self.count()):
if self.isSectionHidden(s):
continue
if transform.mapToPolygon(
rect.translated(s * width + start, 0)).containsPoint(
event.pos(), QtCore.Qt.WindingFill):
self.sectionPressed.emit(s)
return
def paintEvent(self, event):
qp = QtGui.QPainter(self.viewport())
qp.setRenderHints(qp.Antialiasing)
width = self.defaultSectionSize()
delta = self.height()
# add offset if the view is horizontally scrolled
qp.translate(self.sectionViewportPosition(0) - .5, -.5)
fmDelta = (self.fontMetrics().height() - self.fontMetrics().descent()) * .5
# create a reference rectangle (note that the negative height)
rect = QtCore.QRectF(0, 0, width, -delta)
diagonal = hypot(delta, delta)
for s in range(self.count()):
if self.isSectionHidden(s):
continue
qp.save()
qp.save()
qp.setPen(self.borderPen)
# apply a "shear" transform making the rectangle a parallelogram;
# since the transformation is applied top to bottom
# we translate vertically to the bottom of the view
# and draw the "negative height" rectangle
qp.setTransform(qp.transform().translate(s * width, delta).shear(-1, 0))
qp.drawRect(rect)
qp.setPen(QtCore.Qt.NoPen)
qp.setBrush(self.labelBrush)
qp.drawRect(rect.adjusted(2, -2, -2, 2))
qp.restore()
qp.translate(s * width + width, delta)
qp.rotate(-45)
label = str(self.model().headerData(s, QtCore.Qt.Horizontal))
elidedLabel = self.fontMetrics().elidedText(
label, QtCore.Qt.ElideRight, diagonal - self.fontEllipsisSize)
qp.drawText(0, -fmDelta, elidedLabel)
qp.restore()
class AngledTable(QtWidgets.QTableView):
def __init__(self, *args, **kwargs):
QtWidgets.QTableView.__init__(self, *args, **kwargs)
self.setHorizontalHeader(AngledHeader(self))
self.verticalScrollBarSpacer = QtWidgets.QWidget()
self.addScrollBarWidget(self.verticalScrollBarSpacer, QtCore.Qt.AlignTop)
self.fixLock = False
def setModel(self, model):
if self.model():
self.model().headerDataChanged.disconnect(self.fixViewport)
QtWidgets.QTableView.setModel(self, model)
model.headerDataChanged.connect(self.fixViewport)
def fixViewport(self):
if self.fixLock:
return
self.fixLock = True
# delay the viewport/scrollbar states since the view has to process its
# new header data first
QtCore.QTimer.singleShot(0, self.delayedFixViewport)
def delayedFixViewport(self):
# add a right margin through the horizontal scrollbar range
QtWidgets.QApplication.processEvents()
header = self.horizontalHeader()
if not header.isVisible():
self.verticalScrollBarSpacer.setFixedHeight(0)
self.updateGeometries()
return
self.verticalScrollBarSpacer.setFixedHeight(header.sizeHint().height())
bar = self.horizontalScrollBar()
bar.blockSignals(True)
step = bar.singleStep() * (header.height() / header.defaultSectionSize())
bar.setMaximum(bar.maximum() + step)
bar.blockSignals(False)
self.fixLock = False
def resizeEvent(self, event):
# ensure that the viewport and scrollbars are updated whenever
# the table size change
QtWidgets.QTableView.resizeEvent(self, event)
self.fixViewport()
class TestWidget(QtWidgets.QWidget):
def __init__(self):
QtWidgets.QWidget.__init__(self)
l = QtWidgets.QGridLayout()
self.setLayout(l)
self.table = AngledTable()
l.addWidget(self.table)
model = QtGui.QStandardItemModel(4, 5)
self.table.setModel(model)
self.table.setHorizontalScrollMode(self.table.ScrollPerPixel)
model.setVerticalHeaderLabels(['Location {}'.format(l + 1) for l in range(8)])
columns = ['Column {}'.format(c + 1) for c in range(8)]
columns[3] += ' very, very, very, very, very, very, long'
model.setHorizontalHeaderLabels(columns)
if __name__ == '__main__':
app = QtWidgets.QApplication(sys.argv)
w = TestWidget()
w.show()
sys.exit(app.exec_())
Please note that I edited the painting and click detection code using QTransforms instead QPolygons: while it's a bit more complex to understand its mechanics, it's faster than creating a polygon and computing its points each time a column header has to be drawn.
Also, I've added support for maximum header height (in case any header label get too long), and a "spacer" widget that shifts the vertical scrollbar to the actual "beginning" of the table contents.
musicamante posted such an excellent answer that I've used it as the basis to add a few more (stolen) bits. In this code, when a user double clicks an angled header they are greeted with a popup where they can rename the header. Because of the wonderful code that music provided, it redraws everything automatically.
import sys
from math import sqrt, sin, acos, hypot, degrees, radians
from PySide2 import QtCore, QtGui, QtWidgets
class AngledHeader(QtWidgets.QHeaderView):
borderPen = QtGui.QColor(0, 190, 255)
labelBrush = QtGui.QColor(255, 212, 0)
def __init__(self, parent=None):
QtWidgets.QHeaderView.__init__(self, QtCore.Qt.Horizontal, parent)
self.setSectionResizeMode(self.Fixed)
self.setDefaultSectionSize(sqrt((self.fontMetrics().height() + 4)** 2 *2))
self.setSectionsClickable(True)
def sizeHint(self):
# compute the minimum height using the maximum header
# label "hypotenuse"'s
fm = self.fontMetrics()
width = minSize = self.defaultSectionSize()
count = self.count()
for s in range(count):
if self.isSectionHidden(s):
continue
# compute the diagonal of the text's bounding rect,
# shift its angle by 45° to get the minimum required
# height
rect = fm.boundingRect(str(self.model().headerData(s, QtCore.Qt.Horizontal)) + ' ')
diag = hypot(rect.width(), rect.height())
# get the angle of the boundingRect using the
# "Law of cosines":
# https://en.wikipedia.org/wiki/Law_of_cosines
angle = degrees(acos((diag ** 2 + rect.width() ** 2 - rect.height() ** 2) / (2. * diag * rect.width())))
# compute the minimum required height using the
# angle found above
minSize = max(minSize, sin(radians(angle + 45)) * diag)
hint = QtCore.QSize(width * count + 2000, minSize)
return hint
def mousePressEvent(self, event):
width = self.defaultSectionSize()
first = self.sectionViewportPosition(0)
rect = QtCore.QRect(0, 0, width, -self.height())
transform = QtGui.QTransform().translate(0, self.height()).shear(-1, 0)
for s in range(self.count()):
if self.isSectionHidden(s):
continue
if transform.mapToPolygon(rect.translated(s * width + first,
0)).containsPoint(event.pos(), QtCore.Qt.WindingFill):
self.sectionPressed.emit(s)
self.last = ("Click", s) #log initial click and define the column index
return
def mouseReleaseEvent(self, event):
if self.last[0] == "Double Click":#if this was a double click then we have work to do
index = self.last[1]
oldHeader = str(self.model().headerData(index, QtCore.Qt.Horizontal))
newHeader, ok = QtWidgets.QInputDialog.getText(self,
'Change header label for column %d' % index,
'Header:',
QtWidgets.QLineEdit.Normal,
oldHeader)
if ok:
self.model().horizontalHeaderItem(index).setText(newHeader)
self.update()
def mouseDoubleClickEvent(self, event):
self.last = ("Double Click", self.last[1])
#log that it's a double click and pass on the index
def paintEvent(self, event):
qp = QtGui.QPainter(self.viewport())
qp.setRenderHints(qp.Antialiasing)
width = self.defaultSectionSize()
delta = self.height()
# add offset if the view is horizontally scrolled
qp.translate(self.sectionViewportPosition(0) - .5, -.5)
fmDelta = (self.fontMetrics().height() - self.fontMetrics().descent()) * .5
# create a reference rectangle (note that the negative height)
rect = QtCore.QRectF(0, 0, width, -delta)
for s in range(self.count()):
if self.isSectionHidden(s):
continue
qp.save()
qp.save()
qp.setPen(self.borderPen)
# apply a "shear" transform making the rectangle a parallelogram;
# since the transformation is applied top to bottom
# we translate vertically to the bottom of the view
# and draw the "negative height" rectangle
qp.setTransform(qp.transform().translate(s * width, delta).shear(-1, 0))
qp.drawRect(rect)
qp.setPen(QtCore.Qt.NoPen)
qp.setBrush(self.labelBrush)
qp.drawRect(rect.adjusted(2, -2, -2, 2))
qp.restore()
qp.translate(s * width + width, delta)
qp.rotate(-45)
qp.drawText(0, -fmDelta, str(self.model().headerData(s, QtCore.Qt.Horizontal)))
qp.restore()
class AngledTable(QtWidgets.QTableView):
def __init__(self, *args, **kwargs):
QtWidgets.QTableView.__init__(self, *args, **kwargs)
self.setHorizontalHeader(AngledHeader(self))
self.fixLock = False
def setModel(self, model):
if self.model():
self.model().headerDataChanged.disconnect(self.fixViewport)
QtWidgets.QTableView.setModel(self, model)
model.headerDataChanged.connect(self.fixViewport)
def fixViewport(self):
if self.fixLock:
return
self.fixLock = True
# delay the viewport/scrollbar states since the view has to process its
# new header data first
QtCore.QTimer.singleShot(0, self.delayedFixViewport)
def delayedFixViewport(self):
# add a right margin through the horizontal scrollbar range
QtWidgets.QApplication.processEvents()
header = self.horizontalHeader()
bar = self.horizontalScrollBar()
bar.blockSignals(True)
step = bar.singleStep() * (header.height() / header.defaultSectionSize())
bar.setMaximum(bar.maximum() + step)
bar.blockSignals(False)
self.fixLock = False
def resizeEvent(self, event):
# ensure that the viewport and scrollbars are updated whenever
# the table size change
QtWidgets.QTableView.resizeEvent(self, event)
self.fixViewport()
class TestWidget(QtWidgets.QWidget):
def __init__(self):
QtWidgets.QWidget.__init__(self)
l = QtWidgets.QGridLayout()
self.setLayout(l)
self.table = AngledTable()
l.addWidget(self.table)
model = QtGui.QStandardItemModel(4, 5)
self.table.setModel(model)
self.table.setHorizontalScrollMode(self.table.ScrollPerPixel)
self.table.headerlist = ['Column{}'.format(c + 1) for c in range(8)]
model.setVerticalHeaderLabels(['Location 1', 'Location 2', 'Location 3', 'Location 4'])
model.setHorizontalHeaderLabels(self.table.headerlist)
if __name__ == '__main__':
app = QtWidgets.QApplication(sys.argv)
w = TestWidget()
w.show()
sys.exit(app.exec_())
I would like to add mathematical expressions to the table labels (e.g.: 2^3 should be properly formatted)
Here is a simple example of a table:
http://thomas-cokelaer.info/blog/2012/10/pyqt4-example-of-tablewidget-usage/
setHorizontalHeaderLabels accepts string, only.
I wonder if is it possible to implement somehow this matplotlib approach:
matplotlib - write TeX on Qt form
are there other options?
I've also been trying for some time to display complex labels in the header of a QTableWidget. I was able to do it by reimplementing the paintSection method of a QHeaderView and painting manually the label with a QTextDocument as described in a thread on Qt Centre.
However, this solution was somewhat limited compared to what could be done with LaTex. I thought this could be a good idea to try the approach you suggested in your OP, i.e. using the capability of matplotlib to render LaTex in PySide.
1. Convert matplotlib Figure to QPixmap
First thing that is required in this approach is to be able to convert matplotlib figure in a format that can be easily painted on any QWidget. Below is a function that take a mathTex expression as input and convert it through matplotlib to a QPixmap.
import sys
import matplotlib as mpl
from matplotlib.backends.backend_agg import FigureCanvasAgg
from PySide import QtGui, QtCore
def mathTex_to_QPixmap(mathTex, fs):
#---- set up a mpl figure instance ----
fig = mpl.figure.Figure()
fig.patch.set_facecolor('none')
fig.set_canvas(FigureCanvasAgg(fig))
renderer = fig.canvas.get_renderer()
#---- plot the mathTex expression ----
ax = fig.add_axes([0, 0, 1, 1])
ax.axis('off')
ax.patch.set_facecolor('none')
t = ax.text(0, 0, mathTex, ha='left', va='bottom', fontsize=fs)
#---- fit figure size to text artist ----
fwidth, fheight = fig.get_size_inches()
fig_bbox = fig.get_window_extent(renderer)
text_bbox = t.get_window_extent(renderer)
tight_fwidth = text_bbox.width * fwidth / fig_bbox.width
tight_fheight = text_bbox.height * fheight / fig_bbox.height
fig.set_size_inches(tight_fwidth, tight_fheight)
#---- convert mpl figure to QPixmap ----
buf, size = fig.canvas.print_to_buffer()
qimage = QtGui.QImage.rgbSwapped(QtGui.QImage(buf, size[0], size[1],
QtGui.QImage.Format_ARGB32))
qpixmap = QtGui.QPixmap(qimage)
return qpixmap
2. Paint the QPixmaps to the header of a QTableWidget
The next step is to paint the QPixmap in the header of a QTableWidget. As shown below, I've done it by sub-classing QTableWidget and reimplementing the setHorizontalHeaderLabels method, which is used to convert the mathTex expressions for the labels into QPixmap and to pass it as a list to a subclass of QHeaderView. The QPixmap are then painted within a reimplementation of the paintSection method of QHeaderView and the height of the header is set up to fit the height of the mathTex expression in the reimplementation of the sizeHint methods.
class MyQTableWidget(QtGui.QTableWidget):
def __init__(self, parent=None):
super(MyQTableWidget, self).__init__(parent)
self.setHorizontalHeader(MyHorizHeader(self))
def setHorizontalHeaderLabels(self, headerLabels, fontsize):
qpixmaps = []
indx = 0
for labels in headerLabels:
qpixmaps.append(mathTex_to_QPixmap(labels, fontsize))
self.setColumnWidth(indx, qpixmaps[indx].size().width() + 16)
indx += 1
self.horizontalHeader().qpixmaps = qpixmaps
super(MyQTableWidget, self).setHorizontalHeaderLabels(headerLabels)
class MyHorizHeader(QtGui.QHeaderView):
def __init__(self, parent):
super(MyHorizHeader, self).__init__(QtCore.Qt.Horizontal, parent)
self.setClickable(True)
self.setStretchLastSection(True)
self.qpixmaps = []
def paintSection(self, painter, rect, logicalIndex):
if not rect.isValid():
return
#------------------------------ paint section (without the label) ----
opt = QtGui.QStyleOptionHeader()
self.initStyleOption(opt)
opt.rect = rect
opt.section = logicalIndex
opt.text = ""
#---- mouse over highlight ----
mouse_pos = self.mapFromGlobal(QtGui.QCursor.pos())
if rect.contains(mouse_pos):
opt.state |= QtGui.QStyle.State_MouseOver
#---- paint ----
painter.save()
self.style().drawControl(QtGui.QStyle.CE_Header, opt, painter, self)
painter.restore()
#------------------------------------------- paint mathText label ----
qpixmap = self.qpixmaps[logicalIndex]
#---- centering ----
xpix = (rect.width() - qpixmap.size().width()) / 2. + rect.x()
ypix = (rect.height() - qpixmap.size().height()) / 2.
#---- paint ----
rect = QtCore.QRect(xpix, ypix, qpixmap.size().width(),
qpixmap.size().height())
painter.drawPixmap(rect, qpixmap)
def sizeHint(self):
baseSize = QtGui.QHeaderView.sizeHint(self)
baseHeight = baseSize.height()
if len(self.qpixmaps):
for pixmap in self.qpixmaps:
baseHeight = max(pixmap.height() + 8, baseHeight)
baseSize.setHeight(baseHeight)
self.parentWidget().repaint()
return baseSize
3. Application
Below is an example of a simple application of the above.
if __name__ == '__main__':
app = QtGui.QApplication(sys.argv)
w = MyQTableWidget()
w.verticalHeader().hide()
headerLabels = [
'$C_{soil}=(1 - n) C_m + \\theta_w C_w$',
'$k_{soil}=\\frac{\\sum f_j k_j \\theta_j}{\\sum f_j \\theta_j}$',
'$\\lambda_{soil}=k_{soil} / C_{soil}$']
w.setColumnCount(len(headerLabels))
w.setHorizontalHeaderLabels(headerLabels, 25)
w.setRowCount(3)
w.setAlternatingRowColors(True)
k = 1
for j in range(3):
for i in range(3):
w.setItem(i, j, QtGui.QTableWidgetItem('Value %i' % (k)))
k += 1
w.show()
w.resize(700, 200)
sys.exit(app.exec_())
which results in:
The solution is not perfect, but it is a good starting point. I'll update it when I will improve it for my own application.
I want to rotate QRectF in PyQT4 by given angle around bottom left corner. I know how to draw a rectangle but I'm stuck on how to rotate it. I tried with rotate(), but it rotates the coordinate system the given angle clockwise.
Is there any easy solution (except drawing a Polygon by changing coordinates)?
margin = 10
width = 100
depth = 20
self.p = QPainter(self)
self.rectangle = QRectF(margin, margin, width, depth)
self.angle = 30
self.p.rotate(self.angle)
self.p.drawRect(self.rectangle)
self.p.end()
you can move the rotation center (always top left corner) to an arbitrary point of the widget by painter.translate(), paint the rectangle with top left corner in the rotation center, calculate the x- and y- offset of your wanted rotation center and move the object again, then rotate the coordinate system for the next object. here a working example in pyqt5, replace QtWidgets by QtGui for pyqt4:
import sys
import math
from PyQt5 import QtCore, QtGui, QtWidgets
class MeinWidget(QtWidgets.QWidget):
def __init__(self, parent=None):
QtWidgets.QWidget.__init__(self, parent)
self.setGeometry(200,50,300,300)
self.pen1 = QtGui.QPen(QtGui.QColor(0,0,0))
self.pen2 = QtGui.QPen(QtGui.QColor(255,0,0))
self.pen3 = QtGui.QPen(QtGui.QColor(0,255,0))
self.pen4 = QtGui.QPen(QtGui.QColor(0,0,255))
self.brush = QtGui.QBrush(QtGui.QColor(255,255,255))
self.pens = (self.pen1, self.pen2, self.pen3, self.pen4)
self.rw = 100
self.rh = 50
def paintEvent(self, event):
painter = QtGui.QPainter(self)
painter.translate(QtCore.QPointF(self.rw,self.rh)) # move rotation center to an arbitrary point of widget
angle = 10
for i in range(0,len(self.pens)):
dy = self.rh - self.rh*math.cos(math.radians(angle)) # vertical offset of bottom left corner
dx = self.rh*math.sin(math.radians(angle)) # horizontal offset of bottom left corner
p = self.pens[i]
p.setWidth(3)
painter.setPen(p)
painter.drawRect(0,0,self.rw,self.rh)
painter.translate(QtCore.QPointF(dx,dy)) # move the wanted rotation center to old position
painter.rotate(angle)
angle += 10
app = QtWidgets.QApplication(sys.argv)
widget = MeinWidget()
widget.show()
sys.exit(app.exec_())
looks like this: