butterfly_viewer.aux_rulers

Ruler items for CustomQGraphicsScene.

Not intended as a script. Used in Butterfly Viewer.

RulerItem creates a movable ruler on QGraphicsScene with specified units of length.

  1#!/usr/bin/env python3
  2
  3"""Ruler items for CustomQGraphicsScene.
  4
  5Not intended as a script. Used in Butterfly Viewer.
  6
  7RulerItem creates a movable ruler on QGraphicsScene with specified units of length.
  8"""
  9# SPDX-License-Identifier: GPL-3.0-or-later
 10
 11
 12
 13import sys
 14import math
 15from PyQt5 import QtWidgets, QtCore, QtGui
 16
 17
 18
 19class CustomItem(QtWidgets.QGraphicsEllipseItem):
 20    """Create an endpoint for RulerItem and handle change in ruler position.
 21
 22    Instantiated with a QRectF as input argument. For example:
 23        rect     = QtCore.QRectF(point_topleft, point_bottomright)
 24        endpoint = CustomItem(rect)
 25
 26    As implemented in RulerItem, the two endpoints reference each other when an endpoint is dragged.
 27    This allows the other graphics items of the RulerItem to follow the endpoint movement.
 28    """
 29
 30    def __init__(self, *args, **kwargs):
 31        super().__init__(*args, **kwargs)
 32
 33        self.setFlag(self.ItemIsMovable)
 34        self.setFlag(self.ItemSendsGeometryChanges)
 35        self.setFlag(self.ItemIgnoresTransformations)
 36        self.line = None
 37        self.is_point = None
 38        self.text = None
 39        self.text1 = None
 40        self.text2 = None
 41        self.unit = "px"
 42        self.px_per_unit = 1.0
 43        self.relative_origin_position = "bottomleft"
 44        self.y_origin = -1
 45
 46    def add_line(self, line, is_point):
 47        """Add QGraphicsLineItem as reference.
 48        
 49        Args:
 50            line (QGraphicsLineItem)
 51            is_point (bool): True if endpoint is moving; False if static."""
 52        self.line = line
 53        self.is_point = is_point
 54
 55    def add_text(self, text):
 56        """QGraphicsTextItem: Add text item to center of ruler."""
 57        self.text = text
 58
 59    def add_text1(self, text):
 60        """QGraphicsTextItem: Add text item to first endpoint of ruler."""
 61        self.text1 = text
 62
 63    def add_text2(self, text):
 64        """QGraphicsTextItem: Add text item to second endpoint of ruler."""
 65        self.text2 = text
 66
 67    def set_px_per_unit(self, px_per_unit):
 68        """float: Set conversion for pixels to specified unit of length."""
 69        self.px_per_unit = px_per_unit
 70
 71    def set_unit(self, unit):
 72        """str: Set abbreviation for unit of length (for example, "mm" or "px")."""
 73        self.unit = unit
 74
 75    def set_relative_origin_position(self, relative_origin_position):
 76        """Set position of origin for coordinates, distances, and angles.
 77
 78        Two options for relative origin:
 79            "topleft" has (0,0) at the top-left pixel of the image, which is typical for graphics
 80             systems. One can think of this as a standard XY coordinate system mirrored about the 
 81             X-axis, where Y increases downwards. This means clockwise rotation is a positive angle.
 82             
 83            "bottomright" has (0,0) at the bottom-left pixel of the image, just like a standard XY 
 84             coordinate system. This means counter-clockwise rotation is a positive angle.
 85        
 86        Args:
 87            relative_origin_position (str): "topleft" or "bottomleft"
 88        """
 89        self.relative_origin_position = relative_origin_position
 90        if self.relative_origin_position == "topleft":
 91            self.y_origin = +1
 92        elif self.relative_origin_position == "bottomleft":
 93            self.y_origin = -1
 94
 95    def itemChange(self, change, value):
 96        """Extend itemChange to update the positions and texts of the ruler line and labels."""
 97        if change == self.ItemPositionChange and self.scene():
 98            new_pos = value
 99
100            self.move_line_to_center(new_pos)
101
102            self.update_text()
103            self.move_text(new_pos)
104
105            self.update_text1()
106            self.move_text1(new_pos)
107
108            self.update_text2()
109            self.move_text2(new_pos)
110
111        return super(CustomItem, self).itemChange(change, value)
112
113    def move_line_to_center(self, new_pos):
114        """QPointF: Set the center of the ruler line to a position."""
115        x_offset = self.rect().x() + self.rect().width()/2
116        y_offset = self.rect().y() + self.rect().height()/2
117
118        new_center_pos = QtCore.QPointF(new_pos.x()+x_offset, new_pos.y()+y_offset)
119
120        p1 = new_center_pos if self.is_point else self.line.line().p1()
121        p2 = self.line.line().p2() if self.is_point else new_center_pos
122
123        self.line.setLine(QtCore.QLineF(p1, p2))
124
125    def update_text(self):
126        """Refresh the text of the ruler's center label."""
127        length_px = self.get_line_length(self.line.line())
128        unit = self.unit
129        px_per_unit = self.px_per_unit
130        length_unit = length_px/px_per_unit
131        string_length = "{:.1f}".format(length_unit) + " " + unit
132        string = "<div style='background:rgba(0, 0, 0, 91);'>" + string_length + "</div>"
133        self.text.setHtml(string)
134
135    def update_text1(self):
136        """Refresh the text of the ruler's endpoint 1 label."""
137        length_px = self.get_line_length(self.line.line())
138        unit = self.unit
139        px_per_unit = self.px_per_unit
140        p1 = self.line.line().p1()
141        p2 = self.line.line().p2()
142
143        length_unit = length_px/px_per_unit
144        dx_unit = (p1.x()-p2.x())/px_per_unit
145        dy_unit = (p1.y()-p2.y())/px_per_unit
146        dy_unit *= self.y_origin
147        ang = math.degrees(math.atan2(dy_unit, dx_unit))
148
149        string_abs = "|v|  " + "{:.1f}".format(length_unit) + " " + unit
150        string_dx = "⬌  " + "{:.1f}".format(dx_unit) + " " + unit
151        string_dy = "⬍  " + "{:.1f}".format(dy_unit) + " " + unit
152        string_ang = "∠  " + "{:.1f}".format(ang) + "°"
153        string = "<div style='background:rgba(0, 0, 0, 91);'>" + string_abs + "<br>" + string_dx + "<br>" + string_dy + "<br>" + string_ang + "</div>"
154        self.text1.setHtml(string)
155
156    def update_text2(self):
157        """Refresh the text of the ruler's endpoint 2 label."""
158        length_px = self.get_line_length(self.line.line())
159        unit = self.unit
160        px_per_unit = self.px_per_unit
161        p1 = self.line.line().p1()
162        p2 = self.line.line().p2()
163
164        length_unit = length_px/px_per_unit
165        dx_unit = (p2.x()-p1.x())/px_per_unit
166        dy_unit = (p2.y()-p1.y())/px_per_unit
167        dy_unit *= self.y_origin
168        ang = math.degrees(math.atan2(dy_unit, dx_unit))
169
170        string_abs = "|v|  " + "{:.1f}".format(length_unit) + " " + unit
171        string_dx = "⬌  " + "{:.1f}".format(dx_unit) + " " + unit
172        string_dy = "⬍  " + "{:.1f}".format(dy_unit) + " " + unit
173        string_ang = "∠  " + "{:.1f}".format(ang) + "°"
174        string = "<div style='background:rgba(0, 0, 0, 91);'>" + string_abs + "<br>" + string_dx + "<br>" + string_dy + "<br>" + string_ang + "</div>"
175        self.text2.setHtml(string)
176
177    def move_text(self, new_pos):
178        """QPointF: Set the position of the ruler's center label."""
179        if self.text:
180            center_pos = self.line.line().center()
181            x_offset = center_pos.x()
182            y_offset = center_pos.y()
183            new_pos = QtCore.QPointF(x_offset, y_offset)
184            self.text.setPos(new_pos)
185
186    def move_text1(self, new_pos):
187        """QPointF: Set the position of the ruler's endpoint 1 label."""
188        if self.text:
189            pos = self.line.line().p1()
190            x_offset = pos.x()
191            y_offset = pos.y()
192            new_pos = QtCore.QPointF(x_offset, y_offset)
193            self.text1.setPos(new_pos)
194
195    def move_text2(self, new_pos):
196        """QPointF: Set the position of the ruler's endpoint 2 label."""
197        if self.text:
198            pos = self.line.line().p2()
199            x_offset = pos.x()
200            y_offset = pos.y()
201            new_pos = QtCore.QPointF(x_offset, y_offset)
202            self.text2.setPos(new_pos)
203
204    def refresh_positions(self):
205        """Convenience function to refresh (update) positions of the ruler's line and endpoints."""
206        self.move_line_to_center(self.pos())
207        self.move_text(self.pos())
208        self.update_text()
209        self.update_text1()
210        self.update_text2()
211
212    def get_line_length(self, line):
213        """Calculate the length of a QLineF.
214        
215        Args:
216            line (QLineF)
217            
218        Returns:
219            length (float)
220        """
221        dx = line.x2() - line.x1()
222        dy = line.y2() - line.y1()
223        length = math.sqrt(dx**2 + dy**2)
224        return length
225
226
227
228class RulerItem(QtWidgets.QGraphicsRectItem):
229    """Create a movable ruler on QGraphicsScene with a specified unit of length.
230
231    Features:
232        Draggable endpoints.
233        Center label showing absolute length.
234        Endpoint labels showing difference in absolute length, horizontal and vertical delta, and angle.
235
236    Args:
237        unit (str): The text for labeling units of ruler values.
238        px_per_unit (float): The conversion for pixels to units. For example, 10 means 10 
239            pixels-per-unit, meaning the ruler value will show 1 when measuring 10 pixels. Set to 
240            1.0 if the ruler has units of pixels.
241        initial_pos_p1 (QPointF): The position of endpoint 1 on the scene.
242        initial_pos_p2 (QPointF): The position of endpoint 2 on the scene.
243        relative_origin_position (str): The orientation of the origin for coordinate system 
244            ("topleft" or "bottomleft").
245    """
246
247    def __init__(self, unit = "px", px_per_unit = None, initial_pos_p1=None, initial_pos_p2=None, relative_origin_position="bottomleft"):
248        super().__init__()
249
250        self.unit = unit
251        self.px_per_unit = px_per_unit
252        self.relative_origin_position = relative_origin_position
253
254        if not initial_pos_p1:
255            initial_pos_p1 = QtCore.QPointF(10,10)
256        if not initial_pos_p2:
257            initial_pos_p2 = QtCore.QPointF(100,200)
258
259        pen = QtGui.QPen()
260        pen.setWidth(2)
261        pen.setCosmetic(True)
262        pen.setColor(QtCore.Qt.white) # setColor also works
263        pen.setCapStyle(QtCore.Qt.SquareCap)
264        pen.setJoinStyle(QtCore.Qt.MiterJoin)
265            
266        brush = QtGui.QBrush()
267        brush.setColor(QtCore.Qt.white)
268        brush.setStyle(QtCore.Qt.SolidPattern)
269
270        brush_black = QtGui.QBrush()
271        brush_black.setColor(QtCore.Qt.black)
272        brush_black.setStyle(QtCore.Qt.SolidPattern)
273
274
275        width = 8
276        height = 8
277        point_topleft = QtCore.QPointF(-width/2, -height/2)
278        point_bottomright = QtCore.QPointF(width/2,height/2)
279        ellipse_rect = QtCore.QRectF(point_topleft, point_bottomright)
280
281        self.ellipse_item1 = CustomItem(ellipse_rect)
282        self.ellipse_item1.setPos(initial_pos_p1)
283        self.ellipse_item1.setBrush(brush_black)
284        self.ellipse_item1.setPen(pen)
285
286
287        text_item = QtWidgets.QGraphicsTextItem("text")
288        text_item.setPos(0,0)
289        font = text_item.font()
290        font.setPointSize(11)
291        text_item.setFont(font)
292        text_item.setDefaultTextColor(QtCore.Qt.white)
293        text_item.setFlags(QtWidgets.QGraphicsItem.ItemIgnoresTransformations) # QtWidgets.QGraphicsItem.ItemIsSelectable
294
295        text_item1 = QtWidgets.QGraphicsTextItem("text")
296        text_item1.setPos(initial_pos_p1)
297        font = text_item1.font()
298        font.setPointSize(10)
299        text_item1.setFont(font)
300        text_item1.setDefaultTextColor(QtCore.Qt.white)
301        text_item1.setFlags(QtWidgets.QGraphicsItem.ItemIgnoresTransformations) # QtWidgets.QGraphicsItem.ItemIsSelectable
302        
303        text_item2 = QtWidgets.QGraphicsTextItem("text")
304        text_item2.setPos(initial_pos_p2)
305        font = text_item2.font()
306        font.setPointSize(10)
307        text_item2.setFont(font)
308        text_item2.setDefaultTextColor(QtCore.Qt.white)
309        text_item2.setFlags(QtWidgets.QGraphicsItem.ItemIgnoresTransformations) # QtWidgets.QGraphicsItem.ItemIsSelectable
310
311        width = 8
312        height = 8
313
314        point_topleft = QtCore.QPointF(-width/2, -height/2)
315        point_bottomright = QtCore.QPointF(width/2, height/2)
316
317        ellipse_rect = QtCore.QRectF(point_topleft, point_bottomright)
318
319        self.ellipse_item2 = CustomItem(ellipse_rect)
320        self.ellipse_item2.setPos(initial_pos_p2)
321        self.ellipse_item2.setBrush(brush_black)
322        self.ellipse_item2.setPen(pen)
323
324        line_item = QtWidgets.QGraphicsLineItem(QtCore.QLineF(40, 40, 80, 80))
325        pen.setStyle(QtCore.Qt.SolidLine)
326        line_item.setPen(pen)
327        self.shadow_line = QtWidgets.QGraphicsDropShadowEffect(blurRadius=4, color=QtGui.QColor(0, 0, 0, 255), xOffset=0, yOffset=0)
328
329        self.ellipse_item1.set_relative_origin_position(self.relative_origin_position)
330        self.ellipse_item2.set_relative_origin_position(self.relative_origin_position)
331
332        self.ellipse_item1.add_line(line_item, True)
333        self.ellipse_item2.add_line(line_item, False)
334        self.ellipse_item1.add_text(text_item)
335        self.ellipse_item2.add_text(text_item)
336        self.ellipse_item1.add_text1(text_item1)
337        self.ellipse_item2.add_text1(text_item1)
338        self.ellipse_item1.add_text2(text_item2)
339        self.ellipse_item2.add_text2(text_item2)
340
341        self.ellipse_item1.set_px_per_unit(self.px_per_unit)
342        self.ellipse_item2.set_px_per_unit(self.px_per_unit)
343        self.ellipse_item1.set_unit(self.unit)
344        self.ellipse_item2.set_unit(self.unit)
345
346        line_item.setParentItem(self)
347        self.ellipse_item1.setParentItem(self)
348        self.ellipse_item2.setParentItem(self)
349        text_item.setParentItem(self)
350        text_item1.setParentItem(self)
351        text_item2.setParentItem(self)
352
353        self.ellipse_item2.refresh_positions()
354        self.ellipse_item1.refresh_positions()
355
356    def set_and_refresh_px_per_unit(self, px_per_unit):
357        """float: Set and refresh units conversion factor (for example, if the conversion is recalculated)."""
358        self.px_per_unit = px_per_unit
359        self.ellipse_item1.set_px_per_unit(self.px_per_unit)
360        self.ellipse_item2.set_px_per_unit(self.px_per_unit)
361        self.ellipse_item2.refresh_positions()
362        self.ellipse_item1.refresh_positions()
363
364    def set_and_refresh_relative_origin_position(self, relative_origin_position):
365        """str: Set and refresh orientation of coordinate system (for example, if the orientation setting is changed)."""
366        self.relative_origin_position = relative_origin_position
367        self.ellipse_item1.set_relative_origin_position(self.relative_origin_position)
368        self.ellipse_item2.set_relative_origin_position(self.relative_origin_position)
369        self.ellipse_item2.refresh_positions()
370        self.ellipse_item1.refresh_positions()
371        
372
373
374def main():
375    app =QtWidgets.QApplication(sys.argv)
376
377    scene = QtWidgets.QGraphicsScene()
378    pixmap_item = QtWidgets.QGraphicsPixmapItem()
379
380    pixmap = QtGui.QPixmap(r"C:\image.png")
381    pixmap_item.setPixmap(pixmap)
382
383    scene.addItem(pixmap_item)
384
385    ruler = RulerItem()
386
387    scene.addItem(ruler)
388
389    ruler.setPos(50,100)
390
391    view = QtWidgets.QGraphicsView(scene)
392    view.show()
393
394    sys.exit(app.exec_())
395
396
397
398if __name__ == '__main__':
399    main()
class CustomItem(PyQt5.QtWidgets.QGraphicsEllipseItem):
 20class CustomItem(QtWidgets.QGraphicsEllipseItem):
 21    """Create an endpoint for RulerItem and handle change in ruler position.
 22
 23    Instantiated with a QRectF as input argument. For example:
 24        rect     = QtCore.QRectF(point_topleft, point_bottomright)
 25        endpoint = CustomItem(rect)
 26
 27    As implemented in RulerItem, the two endpoints reference each other when an endpoint is dragged.
 28    This allows the other graphics items of the RulerItem to follow the endpoint movement.
 29    """
 30
 31    def __init__(self, *args, **kwargs):
 32        super().__init__(*args, **kwargs)
 33
 34        self.setFlag(self.ItemIsMovable)
 35        self.setFlag(self.ItemSendsGeometryChanges)
 36        self.setFlag(self.ItemIgnoresTransformations)
 37        self.line = None
 38        self.is_point = None
 39        self.text = None
 40        self.text1 = None
 41        self.text2 = None
 42        self.unit = "px"
 43        self.px_per_unit = 1.0
 44        self.relative_origin_position = "bottomleft"
 45        self.y_origin = -1
 46
 47    def add_line(self, line, is_point):
 48        """Add QGraphicsLineItem as reference.
 49        
 50        Args:
 51            line (QGraphicsLineItem)
 52            is_point (bool): True if endpoint is moving; False if static."""
 53        self.line = line
 54        self.is_point = is_point
 55
 56    def add_text(self, text):
 57        """QGraphicsTextItem: Add text item to center of ruler."""
 58        self.text = text
 59
 60    def add_text1(self, text):
 61        """QGraphicsTextItem: Add text item to first endpoint of ruler."""
 62        self.text1 = text
 63
 64    def add_text2(self, text):
 65        """QGraphicsTextItem: Add text item to second endpoint of ruler."""
 66        self.text2 = text
 67
 68    def set_px_per_unit(self, px_per_unit):
 69        """float: Set conversion for pixels to specified unit of length."""
 70        self.px_per_unit = px_per_unit
 71
 72    def set_unit(self, unit):
 73        """str: Set abbreviation for unit of length (for example, "mm" or "px")."""
 74        self.unit = unit
 75
 76    def set_relative_origin_position(self, relative_origin_position):
 77        """Set position of origin for coordinates, distances, and angles.
 78
 79        Two options for relative origin:
 80            "topleft" has (0,0) at the top-left pixel of the image, which is typical for graphics
 81             systems. One can think of this as a standard XY coordinate system mirrored about the 
 82             X-axis, where Y increases downwards. This means clockwise rotation is a positive angle.
 83             
 84            "bottomright" has (0,0) at the bottom-left pixel of the image, just like a standard XY 
 85             coordinate system. This means counter-clockwise rotation is a positive angle.
 86        
 87        Args:
 88            relative_origin_position (str): "topleft" or "bottomleft"
 89        """
 90        self.relative_origin_position = relative_origin_position
 91        if self.relative_origin_position == "topleft":
 92            self.y_origin = +1
 93        elif self.relative_origin_position == "bottomleft":
 94            self.y_origin = -1
 95
 96    def itemChange(self, change, value):
 97        """Extend itemChange to update the positions and texts of the ruler line and labels."""
 98        if change == self.ItemPositionChange and self.scene():
 99            new_pos = value
100
101            self.move_line_to_center(new_pos)
102
103            self.update_text()
104            self.move_text(new_pos)
105
106            self.update_text1()
107            self.move_text1(new_pos)
108
109            self.update_text2()
110            self.move_text2(new_pos)
111
112        return super(CustomItem, self).itemChange(change, value)
113
114    def move_line_to_center(self, new_pos):
115        """QPointF: Set the center of the ruler line to a position."""
116        x_offset = self.rect().x() + self.rect().width()/2
117        y_offset = self.rect().y() + self.rect().height()/2
118
119        new_center_pos = QtCore.QPointF(new_pos.x()+x_offset, new_pos.y()+y_offset)
120
121        p1 = new_center_pos if self.is_point else self.line.line().p1()
122        p2 = self.line.line().p2() if self.is_point else new_center_pos
123
124        self.line.setLine(QtCore.QLineF(p1, p2))
125
126    def update_text(self):
127        """Refresh the text of the ruler's center label."""
128        length_px = self.get_line_length(self.line.line())
129        unit = self.unit
130        px_per_unit = self.px_per_unit
131        length_unit = length_px/px_per_unit
132        string_length = "{:.1f}".format(length_unit) + " " + unit
133        string = "<div style='background:rgba(0, 0, 0, 91);'>" + string_length + "</div>"
134        self.text.setHtml(string)
135
136    def update_text1(self):
137        """Refresh the text of the ruler's endpoint 1 label."""
138        length_px = self.get_line_length(self.line.line())
139        unit = self.unit
140        px_per_unit = self.px_per_unit
141        p1 = self.line.line().p1()
142        p2 = self.line.line().p2()
143
144        length_unit = length_px/px_per_unit
145        dx_unit = (p1.x()-p2.x())/px_per_unit
146        dy_unit = (p1.y()-p2.y())/px_per_unit
147        dy_unit *= self.y_origin
148        ang = math.degrees(math.atan2(dy_unit, dx_unit))
149
150        string_abs = "|v|  " + "{:.1f}".format(length_unit) + " " + unit
151        string_dx = "⬌  " + "{:.1f}".format(dx_unit) + " " + unit
152        string_dy = "⬍  " + "{:.1f}".format(dy_unit) + " " + unit
153        string_ang = "∠  " + "{:.1f}".format(ang) + "°"
154        string = "<div style='background:rgba(0, 0, 0, 91);'>" + string_abs + "<br>" + string_dx + "<br>" + string_dy + "<br>" + string_ang + "</div>"
155        self.text1.setHtml(string)
156
157    def update_text2(self):
158        """Refresh the text of the ruler's endpoint 2 label."""
159        length_px = self.get_line_length(self.line.line())
160        unit = self.unit
161        px_per_unit = self.px_per_unit
162        p1 = self.line.line().p1()
163        p2 = self.line.line().p2()
164
165        length_unit = length_px/px_per_unit
166        dx_unit = (p2.x()-p1.x())/px_per_unit
167        dy_unit = (p2.y()-p1.y())/px_per_unit
168        dy_unit *= self.y_origin
169        ang = math.degrees(math.atan2(dy_unit, dx_unit))
170
171        string_abs = "|v|  " + "{:.1f}".format(length_unit) + " " + unit
172        string_dx = "⬌  " + "{:.1f}".format(dx_unit) + " " + unit
173        string_dy = "⬍  " + "{:.1f}".format(dy_unit) + " " + unit
174        string_ang = "∠  " + "{:.1f}".format(ang) + "°"
175        string = "<div style='background:rgba(0, 0, 0, 91);'>" + string_abs + "<br>" + string_dx + "<br>" + string_dy + "<br>" + string_ang + "</div>"
176        self.text2.setHtml(string)
177
178    def move_text(self, new_pos):
179        """QPointF: Set the position of the ruler's center label."""
180        if self.text:
181            center_pos = self.line.line().center()
182            x_offset = center_pos.x()
183            y_offset = center_pos.y()
184            new_pos = QtCore.QPointF(x_offset, y_offset)
185            self.text.setPos(new_pos)
186
187    def move_text1(self, new_pos):
188        """QPointF: Set the position of the ruler's endpoint 1 label."""
189        if self.text:
190            pos = self.line.line().p1()
191            x_offset = pos.x()
192            y_offset = pos.y()
193            new_pos = QtCore.QPointF(x_offset, y_offset)
194            self.text1.setPos(new_pos)
195
196    def move_text2(self, new_pos):
197        """QPointF: Set the position of the ruler's endpoint 2 label."""
198        if self.text:
199            pos = self.line.line().p2()
200            x_offset = pos.x()
201            y_offset = pos.y()
202            new_pos = QtCore.QPointF(x_offset, y_offset)
203            self.text2.setPos(new_pos)
204
205    def refresh_positions(self):
206        """Convenience function to refresh (update) positions of the ruler's line and endpoints."""
207        self.move_line_to_center(self.pos())
208        self.move_text(self.pos())
209        self.update_text()
210        self.update_text1()
211        self.update_text2()
212
213    def get_line_length(self, line):
214        """Calculate the length of a QLineF.
215        
216        Args:
217            line (QLineF)
218            
219        Returns:
220            length (float)
221        """
222        dx = line.x2() - line.x1()
223        dy = line.y2() - line.y1()
224        length = math.sqrt(dx**2 + dy**2)
225        return length

Create an endpoint for RulerItem and handle change in ruler position.

Instantiated with a QRectF as input argument. For example: rect = QtCore.QRectF(point_topleft, point_bottomright) endpoint = CustomItem(rect)

As implemented in RulerItem, the two endpoints reference each other when an endpoint is dragged. This allows the other graphics items of the RulerItem to follow the endpoint movement.

def add_line(self, line, is_point):
47    def add_line(self, line, is_point):
48        """Add QGraphicsLineItem as reference.
49        
50        Args:
51            line (QGraphicsLineItem)
52            is_point (bool): True if endpoint is moving; False if static."""
53        self.line = line
54        self.is_point = is_point

Add QGraphicsLineItem as reference.

Arguments:
  • line (QGraphicsLineItem)
  • is_point (bool): True if endpoint is moving; False if static.
def add_text(self, text):
56    def add_text(self, text):
57        """QGraphicsTextItem: Add text item to center of ruler."""
58        self.text = text

QGraphicsTextItem: Add text item to center of ruler.

def add_text1(self, text):
60    def add_text1(self, text):
61        """QGraphicsTextItem: Add text item to first endpoint of ruler."""
62        self.text1 = text

QGraphicsTextItem: Add text item to first endpoint of ruler.

def add_text2(self, text):
64    def add_text2(self, text):
65        """QGraphicsTextItem: Add text item to second endpoint of ruler."""
66        self.text2 = text

QGraphicsTextItem: Add text item to second endpoint of ruler.

def set_px_per_unit(self, px_per_unit):
68    def set_px_per_unit(self, px_per_unit):
69        """float: Set conversion for pixels to specified unit of length."""
70        self.px_per_unit = px_per_unit

float: Set conversion for pixels to specified unit of length.

def set_unit(self, unit):
72    def set_unit(self, unit):
73        """str: Set abbreviation for unit of length (for example, "mm" or "px")."""
74        self.unit = unit

str: Set abbreviation for unit of length (for example, "mm" or "px").

def set_relative_origin_position(self, relative_origin_position):
76    def set_relative_origin_position(self, relative_origin_position):
77        """Set position of origin for coordinates, distances, and angles.
78
79        Two options for relative origin:
80            "topleft" has (0,0) at the top-left pixel of the image, which is typical for graphics
81             systems. One can think of this as a standard XY coordinate system mirrored about the 
82             X-axis, where Y increases downwards. This means clockwise rotation is a positive angle.
83             
84            "bottomright" has (0,0) at the bottom-left pixel of the image, just like a standard XY 
85             coordinate system. This means counter-clockwise rotation is a positive angle.
86        
87        Args:
88            relative_origin_position (str): "topleft" or "bottomleft"
89        """
90        self.relative_origin_position = relative_origin_position
91        if self.relative_origin_position == "topleft":
92            self.y_origin = +1
93        elif self.relative_origin_position == "bottomleft":
94            self.y_origin = -1

Set position of origin for coordinates, distances, and angles.

Two options for relative origin:

"topleft" has (0,0) at the top-left pixel of the image, which is typical for graphics systems. One can think of this as a standard XY coordinate system mirrored about the X-axis, where Y increases downwards. This means clockwise rotation is a positive angle.

"bottomright" has (0,0) at the bottom-left pixel of the image, just like a standard XY coordinate system. This means counter-clockwise rotation is a positive angle.

Arguments:
  • relative_origin_position (str): "topleft" or "bottomleft"
def itemChange(self, change, value):
 96    def itemChange(self, change, value):
 97        """Extend itemChange to update the positions and texts of the ruler line and labels."""
 98        if change == self.ItemPositionChange and self.scene():
 99            new_pos = value
100
101            self.move_line_to_center(new_pos)
102
103            self.update_text()
104            self.move_text(new_pos)
105
106            self.update_text1()
107            self.move_text1(new_pos)
108
109            self.update_text2()
110            self.move_text2(new_pos)
111
112        return super(CustomItem, self).itemChange(change, value)

Extend itemChange to update the positions and texts of the ruler line and labels.

def move_line_to_center(self, new_pos):
114    def move_line_to_center(self, new_pos):
115        """QPointF: Set the center of the ruler line to a position."""
116        x_offset = self.rect().x() + self.rect().width()/2
117        y_offset = self.rect().y() + self.rect().height()/2
118
119        new_center_pos = QtCore.QPointF(new_pos.x()+x_offset, new_pos.y()+y_offset)
120
121        p1 = new_center_pos if self.is_point else self.line.line().p1()
122        p2 = self.line.line().p2() if self.is_point else new_center_pos
123
124        self.line.setLine(QtCore.QLineF(p1, p2))

QPointF: Set the center of the ruler line to a position.

def update_text(self):
126    def update_text(self):
127        """Refresh the text of the ruler's center label."""
128        length_px = self.get_line_length(self.line.line())
129        unit = self.unit
130        px_per_unit = self.px_per_unit
131        length_unit = length_px/px_per_unit
132        string_length = "{:.1f}".format(length_unit) + " " + unit
133        string = "<div style='background:rgba(0, 0, 0, 91);'>" + string_length + "</div>"
134        self.text.setHtml(string)

Refresh the text of the ruler's center label.

def update_text1(self):
136    def update_text1(self):
137        """Refresh the text of the ruler's endpoint 1 label."""
138        length_px = self.get_line_length(self.line.line())
139        unit = self.unit
140        px_per_unit = self.px_per_unit
141        p1 = self.line.line().p1()
142        p2 = self.line.line().p2()
143
144        length_unit = length_px/px_per_unit
145        dx_unit = (p1.x()-p2.x())/px_per_unit
146        dy_unit = (p1.y()-p2.y())/px_per_unit
147        dy_unit *= self.y_origin
148        ang = math.degrees(math.atan2(dy_unit, dx_unit))
149
150        string_abs = "|v|  " + "{:.1f}".format(length_unit) + " " + unit
151        string_dx = "⬌  " + "{:.1f}".format(dx_unit) + " " + unit
152        string_dy = "⬍  " + "{:.1f}".format(dy_unit) + " " + unit
153        string_ang = "∠  " + "{:.1f}".format(ang) + "°"
154        string = "<div style='background:rgba(0, 0, 0, 91);'>" + string_abs + "<br>" + string_dx + "<br>" + string_dy + "<br>" + string_ang + "</div>"
155        self.text1.setHtml(string)

Refresh the text of the ruler's endpoint 1 label.

def update_text2(self):
157    def update_text2(self):
158        """Refresh the text of the ruler's endpoint 2 label."""
159        length_px = self.get_line_length(self.line.line())
160        unit = self.unit
161        px_per_unit = self.px_per_unit
162        p1 = self.line.line().p1()
163        p2 = self.line.line().p2()
164
165        length_unit = length_px/px_per_unit
166        dx_unit = (p2.x()-p1.x())/px_per_unit
167        dy_unit = (p2.y()-p1.y())/px_per_unit
168        dy_unit *= self.y_origin
169        ang = math.degrees(math.atan2(dy_unit, dx_unit))
170
171        string_abs = "|v|  " + "{:.1f}".format(length_unit) + " " + unit
172        string_dx = "⬌  " + "{:.1f}".format(dx_unit) + " " + unit
173        string_dy = "⬍  " + "{:.1f}".format(dy_unit) + " " + unit
174        string_ang = "∠  " + "{:.1f}".format(ang) + "°"
175        string = "<div style='background:rgba(0, 0, 0, 91);'>" + string_abs + "<br>" + string_dx + "<br>" + string_dy + "<br>" + string_ang + "</div>"
176        self.text2.setHtml(string)

Refresh the text of the ruler's endpoint 2 label.

def move_text(self, new_pos):
178    def move_text(self, new_pos):
179        """QPointF: Set the position of the ruler's center label."""
180        if self.text:
181            center_pos = self.line.line().center()
182            x_offset = center_pos.x()
183            y_offset = center_pos.y()
184            new_pos = QtCore.QPointF(x_offset, y_offset)
185            self.text.setPos(new_pos)

QPointF: Set the position of the ruler's center label.

def move_text1(self, new_pos):
187    def move_text1(self, new_pos):
188        """QPointF: Set the position of the ruler's endpoint 1 label."""
189        if self.text:
190            pos = self.line.line().p1()
191            x_offset = pos.x()
192            y_offset = pos.y()
193            new_pos = QtCore.QPointF(x_offset, y_offset)
194            self.text1.setPos(new_pos)

QPointF: Set the position of the ruler's endpoint 1 label.

def move_text2(self, new_pos):
196    def move_text2(self, new_pos):
197        """QPointF: Set the position of the ruler's endpoint 2 label."""
198        if self.text:
199            pos = self.line.line().p2()
200            x_offset = pos.x()
201            y_offset = pos.y()
202            new_pos = QtCore.QPointF(x_offset, y_offset)
203            self.text2.setPos(new_pos)

QPointF: Set the position of the ruler's endpoint 2 label.

def refresh_positions(self):
205    def refresh_positions(self):
206        """Convenience function to refresh (update) positions of the ruler's line and endpoints."""
207        self.move_line_to_center(self.pos())
208        self.move_text(self.pos())
209        self.update_text()
210        self.update_text1()
211        self.update_text2()

Convenience function to refresh (update) positions of the ruler's line and endpoints.

def get_line_length(self, line):
213    def get_line_length(self, line):
214        """Calculate the length of a QLineF.
215        
216        Args:
217            line (QLineF)
218            
219        Returns:
220            length (float)
221        """
222        dx = line.x2() - line.x1()
223        dy = line.y2() - line.y1()
224        length = math.sqrt(dx**2 + dy**2)
225        return length

Calculate the length of a QLineF.

Arguments:
  • line (QLineF)
Returns:
  • length (float)
class RulerItem(PyQt5.QtWidgets.QGraphicsRectItem):
229class RulerItem(QtWidgets.QGraphicsRectItem):
230    """Create a movable ruler on QGraphicsScene with a specified unit of length.
231
232    Features:
233        Draggable endpoints.
234        Center label showing absolute length.
235        Endpoint labels showing difference in absolute length, horizontal and vertical delta, and angle.
236
237    Args:
238        unit (str): The text for labeling units of ruler values.
239        px_per_unit (float): The conversion for pixels to units. For example, 10 means 10 
240            pixels-per-unit, meaning the ruler value will show 1 when measuring 10 pixels. Set to 
241            1.0 if the ruler has units of pixels.
242        initial_pos_p1 (QPointF): The position of endpoint 1 on the scene.
243        initial_pos_p2 (QPointF): The position of endpoint 2 on the scene.
244        relative_origin_position (str): The orientation of the origin for coordinate system 
245            ("topleft" or "bottomleft").
246    """
247
248    def __init__(self, unit = "px", px_per_unit = None, initial_pos_p1=None, initial_pos_p2=None, relative_origin_position="bottomleft"):
249        super().__init__()
250
251        self.unit = unit
252        self.px_per_unit = px_per_unit
253        self.relative_origin_position = relative_origin_position
254
255        if not initial_pos_p1:
256            initial_pos_p1 = QtCore.QPointF(10,10)
257        if not initial_pos_p2:
258            initial_pos_p2 = QtCore.QPointF(100,200)
259
260        pen = QtGui.QPen()
261        pen.setWidth(2)
262        pen.setCosmetic(True)
263        pen.setColor(QtCore.Qt.white) # setColor also works
264        pen.setCapStyle(QtCore.Qt.SquareCap)
265        pen.setJoinStyle(QtCore.Qt.MiterJoin)
266            
267        brush = QtGui.QBrush()
268        brush.setColor(QtCore.Qt.white)
269        brush.setStyle(QtCore.Qt.SolidPattern)
270
271        brush_black = QtGui.QBrush()
272        brush_black.setColor(QtCore.Qt.black)
273        brush_black.setStyle(QtCore.Qt.SolidPattern)
274
275
276        width = 8
277        height = 8
278        point_topleft = QtCore.QPointF(-width/2, -height/2)
279        point_bottomright = QtCore.QPointF(width/2,height/2)
280        ellipse_rect = QtCore.QRectF(point_topleft, point_bottomright)
281
282        self.ellipse_item1 = CustomItem(ellipse_rect)
283        self.ellipse_item1.setPos(initial_pos_p1)
284        self.ellipse_item1.setBrush(brush_black)
285        self.ellipse_item1.setPen(pen)
286
287
288        text_item = QtWidgets.QGraphicsTextItem("text")
289        text_item.setPos(0,0)
290        font = text_item.font()
291        font.setPointSize(11)
292        text_item.setFont(font)
293        text_item.setDefaultTextColor(QtCore.Qt.white)
294        text_item.setFlags(QtWidgets.QGraphicsItem.ItemIgnoresTransformations) # QtWidgets.QGraphicsItem.ItemIsSelectable
295
296        text_item1 = QtWidgets.QGraphicsTextItem("text")
297        text_item1.setPos(initial_pos_p1)
298        font = text_item1.font()
299        font.setPointSize(10)
300        text_item1.setFont(font)
301        text_item1.setDefaultTextColor(QtCore.Qt.white)
302        text_item1.setFlags(QtWidgets.QGraphicsItem.ItemIgnoresTransformations) # QtWidgets.QGraphicsItem.ItemIsSelectable
303        
304        text_item2 = QtWidgets.QGraphicsTextItem("text")
305        text_item2.setPos(initial_pos_p2)
306        font = text_item2.font()
307        font.setPointSize(10)
308        text_item2.setFont(font)
309        text_item2.setDefaultTextColor(QtCore.Qt.white)
310        text_item2.setFlags(QtWidgets.QGraphicsItem.ItemIgnoresTransformations) # QtWidgets.QGraphicsItem.ItemIsSelectable
311
312        width = 8
313        height = 8
314
315        point_topleft = QtCore.QPointF(-width/2, -height/2)
316        point_bottomright = QtCore.QPointF(width/2, height/2)
317
318        ellipse_rect = QtCore.QRectF(point_topleft, point_bottomright)
319
320        self.ellipse_item2 = CustomItem(ellipse_rect)
321        self.ellipse_item2.setPos(initial_pos_p2)
322        self.ellipse_item2.setBrush(brush_black)
323        self.ellipse_item2.setPen(pen)
324
325        line_item = QtWidgets.QGraphicsLineItem(QtCore.QLineF(40, 40, 80, 80))
326        pen.setStyle(QtCore.Qt.SolidLine)
327        line_item.setPen(pen)
328        self.shadow_line = QtWidgets.QGraphicsDropShadowEffect(blurRadius=4, color=QtGui.QColor(0, 0, 0, 255), xOffset=0, yOffset=0)
329
330        self.ellipse_item1.set_relative_origin_position(self.relative_origin_position)
331        self.ellipse_item2.set_relative_origin_position(self.relative_origin_position)
332
333        self.ellipse_item1.add_line(line_item, True)
334        self.ellipse_item2.add_line(line_item, False)
335        self.ellipse_item1.add_text(text_item)
336        self.ellipse_item2.add_text(text_item)
337        self.ellipse_item1.add_text1(text_item1)
338        self.ellipse_item2.add_text1(text_item1)
339        self.ellipse_item1.add_text2(text_item2)
340        self.ellipse_item2.add_text2(text_item2)
341
342        self.ellipse_item1.set_px_per_unit(self.px_per_unit)
343        self.ellipse_item2.set_px_per_unit(self.px_per_unit)
344        self.ellipse_item1.set_unit(self.unit)
345        self.ellipse_item2.set_unit(self.unit)
346
347        line_item.setParentItem(self)
348        self.ellipse_item1.setParentItem(self)
349        self.ellipse_item2.setParentItem(self)
350        text_item.setParentItem(self)
351        text_item1.setParentItem(self)
352        text_item2.setParentItem(self)
353
354        self.ellipse_item2.refresh_positions()
355        self.ellipse_item1.refresh_positions()
356
357    def set_and_refresh_px_per_unit(self, px_per_unit):
358        """float: Set and refresh units conversion factor (for example, if the conversion is recalculated)."""
359        self.px_per_unit = px_per_unit
360        self.ellipse_item1.set_px_per_unit(self.px_per_unit)
361        self.ellipse_item2.set_px_per_unit(self.px_per_unit)
362        self.ellipse_item2.refresh_positions()
363        self.ellipse_item1.refresh_positions()
364
365    def set_and_refresh_relative_origin_position(self, relative_origin_position):
366        """str: Set and refresh orientation of coordinate system (for example, if the orientation setting is changed)."""
367        self.relative_origin_position = relative_origin_position
368        self.ellipse_item1.set_relative_origin_position(self.relative_origin_position)
369        self.ellipse_item2.set_relative_origin_position(self.relative_origin_position)
370        self.ellipse_item2.refresh_positions()
371        self.ellipse_item1.refresh_positions()

Create a movable ruler on QGraphicsScene with a specified unit of length.

Features:

Draggable endpoints. Center label showing absolute length. Endpoint labels showing difference in absolute length, horizontal and vertical delta, and angle.

Arguments:
  • unit (str): The text for labeling units of ruler values.
  • px_per_unit (float): The conversion for pixels to units. For example, 10 means 10 pixels-per-unit, meaning the ruler value will show 1 when measuring 10 pixels. Set to 1.0 if the ruler has units of pixels.
  • initial_pos_p1 (QPointF): The position of endpoint 1 on the scene.
  • initial_pos_p2 (QPointF): The position of endpoint 2 on the scene.
  • relative_origin_position (str): The orientation of the origin for coordinate system ("topleft" or "bottomleft").
def set_and_refresh_px_per_unit(self, px_per_unit):
357    def set_and_refresh_px_per_unit(self, px_per_unit):
358        """float: Set and refresh units conversion factor (for example, if the conversion is recalculated)."""
359        self.px_per_unit = px_per_unit
360        self.ellipse_item1.set_px_per_unit(self.px_per_unit)
361        self.ellipse_item2.set_px_per_unit(self.px_per_unit)
362        self.ellipse_item2.refresh_positions()
363        self.ellipse_item1.refresh_positions()

float: Set and refresh units conversion factor (for example, if the conversion is recalculated).

def set_and_refresh_relative_origin_position(self, relative_origin_position):
365    def set_and_refresh_relative_origin_position(self, relative_origin_position):
366        """str: Set and refresh orientation of coordinate system (for example, if the orientation setting is changed)."""
367        self.relative_origin_position = relative_origin_position
368        self.ellipse_item1.set_relative_origin_position(self.relative_origin_position)
369        self.ellipse_item2.set_relative_origin_position(self.relative_origin_position)
370        self.ellipse_item2.refresh_positions()
371        self.ellipse_item1.refresh_positions()

str: Set and refresh orientation of coordinate system (for example, if the orientation setting is changed).