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_mm (float): The conversion for pixels to millimeters. For example, 10 means 10 
239            pixels-per-mm, meaning the ruler value will show 1 mm 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_mm = None, initial_pos_p1=None, initial_pos_p2=None, relative_origin_position="bottomleft"):
248        super().__init__()
249
250        self.unit = unit
251
252        mm_per_unit = 1.0
253        if "cm" == unit:
254            mm_per_unit = 10.0
255        elif "m" == unit:
256            mm_per_unit = 1000.0
257        elif "in" == unit:
258            mm_per_unit = 25.4
259        elif "ft" == unit:
260            mm_per_unit = 304.8
261        elif "yd" == unit:
262            mm_per_unit = 914.4
263        
264        if "px" == unit:
265            self.px_per_unit = 1.0
266        else:
267            self.px_per_unit = px_per_mm * mm_per_unit
268
269        self._mm_per_unit = mm_per_unit
270
271        self.relative_origin_position = relative_origin_position
272
273        if not initial_pos_p1:
274            initial_pos_p1 = QtCore.QPointF(10,10)
275        if not initial_pos_p2:
276            initial_pos_p2 = QtCore.QPointF(100,200)
277
278        pen = QtGui.QPen()
279        pen.setWidth(2)
280        pen.setCosmetic(True)
281        pen.setColor(QtCore.Qt.white) # setColor also works
282        pen.setCapStyle(QtCore.Qt.SquareCap)
283        pen.setJoinStyle(QtCore.Qt.MiterJoin)
284            
285        brush = QtGui.QBrush()
286        brush.setColor(QtCore.Qt.white)
287        brush.setStyle(QtCore.Qt.SolidPattern)
288
289        brush_black = QtGui.QBrush()
290        brush_black.setColor(QtCore.Qt.black)
291        brush_black.setStyle(QtCore.Qt.SolidPattern)
292
293
294        width = 8
295        height = 8
296        point_topleft = QtCore.QPointF(-width/2, -height/2)
297        point_bottomright = QtCore.QPointF(width/2,height/2)
298        ellipse_rect = QtCore.QRectF(point_topleft, point_bottomright)
299
300        self.ellipse_item1 = CustomItem(ellipse_rect)
301        self.ellipse_item1.setPos(initial_pos_p1)
302        self.ellipse_item1.setBrush(brush_black)
303        self.ellipse_item1.setPen(pen)
304
305
306        text_item = QtWidgets.QGraphicsTextItem("text")
307        text_item.setPos(0,0)
308        font = text_item.font()
309        font.setPointSize(11)
310        text_item.setFont(font)
311        text_item.setDefaultTextColor(QtCore.Qt.white)
312        text_item.setFlags(QtWidgets.QGraphicsItem.ItemIgnoresTransformations) # QtWidgets.QGraphicsItem.ItemIsSelectable
313
314        text_item1 = QtWidgets.QGraphicsTextItem("text")
315        text_item1.setPos(initial_pos_p1)
316        font = text_item1.font()
317        font.setPointSize(10)
318        text_item1.setFont(font)
319        text_item1.setDefaultTextColor(QtCore.Qt.white)
320        text_item1.setFlags(QtWidgets.QGraphicsItem.ItemIgnoresTransformations) # QtWidgets.QGraphicsItem.ItemIsSelectable
321        
322        text_item2 = QtWidgets.QGraphicsTextItem("text")
323        text_item2.setPos(initial_pos_p2)
324        font = text_item2.font()
325        font.setPointSize(10)
326        text_item2.setFont(font)
327        text_item2.setDefaultTextColor(QtCore.Qt.white)
328        text_item2.setFlags(QtWidgets.QGraphicsItem.ItemIgnoresTransformations) # QtWidgets.QGraphicsItem.ItemIsSelectable
329
330        width = 8
331        height = 8
332
333        point_topleft = QtCore.QPointF(-width/2, -height/2)
334        point_bottomright = QtCore.QPointF(width/2, height/2)
335
336        ellipse_rect = QtCore.QRectF(point_topleft, point_bottomright)
337
338        self.ellipse_item2 = CustomItem(ellipse_rect)
339        self.ellipse_item2.setPos(initial_pos_p2)
340        self.ellipse_item2.setBrush(brush_black)
341        self.ellipse_item2.setPen(pen)
342
343        line_item = QtWidgets.QGraphicsLineItem(QtCore.QLineF(40, 40, 80, 80))
344        pen.setStyle(QtCore.Qt.SolidLine)
345        line_item.setPen(pen)
346        self.shadow_line = QtWidgets.QGraphicsDropShadowEffect(blurRadius=4, color=QtGui.QColor(0, 0, 0, 255), xOffset=0, yOffset=0)
347
348        self.ellipse_item1.set_relative_origin_position(self.relative_origin_position)
349        self.ellipse_item2.set_relative_origin_position(self.relative_origin_position)
350
351        self.ellipse_item1.add_line(line_item, True)
352        self.ellipse_item2.add_line(line_item, False)
353        self.ellipse_item1.add_text(text_item)
354        self.ellipse_item2.add_text(text_item)
355        self.ellipse_item1.add_text1(text_item1)
356        self.ellipse_item2.add_text1(text_item1)
357        self.ellipse_item1.add_text2(text_item2)
358        self.ellipse_item2.add_text2(text_item2)
359
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_item1.set_unit(self.unit)
363        self.ellipse_item2.set_unit(self.unit)
364
365        line_item.setParentItem(self)
366        self.ellipse_item1.setParentItem(self)
367        self.ellipse_item2.setParentItem(self)
368        text_item.setParentItem(self)
369        text_item1.setParentItem(self)
370        text_item2.setParentItem(self)
371
372        self.ellipse_item2.refresh_positions()
373        self.ellipse_item1.refresh_positions()
374
375    def set_and_refresh_px_per_unit(self, px_per_unit):
376        """float: Set and refresh units conversion factor (for example, if the conversion is recalculated)."""
377        unit = self.unit
378        if "px" != unit:
379            mm_per_unit = self._mm_per_unit
380            self.px_per_unit = px_per_unit * mm_per_unit
381            self.ellipse_item1.set_px_per_unit(self.px_per_unit)
382            self.ellipse_item2.set_px_per_unit(self.px_per_unit)
383            self.ellipse_item2.refresh_positions()
384            self.ellipse_item1.refresh_positions()
385
386    def set_and_refresh_relative_origin_position(self, relative_origin_position):
387        """str: Set and refresh orientation of coordinate system (for example, if the orientation setting is changed)."""
388        self.relative_origin_position = relative_origin_position
389        self.ellipse_item1.set_relative_origin_position(self.relative_origin_position)
390        self.ellipse_item2.set_relative_origin_position(self.relative_origin_position)
391        self.ellipse_item2.refresh_positions()
392        self.ellipse_item1.refresh_positions()
393        
394
395
396def main():
397    app =QtWidgets.QApplication(sys.argv)
398
399    scene = QtWidgets.QGraphicsScene()
400    pixmap_item = QtWidgets.QGraphicsPixmapItem()
401
402    pixmap = QtGui.QPixmap(r"C:\image.png")
403    pixmap_item.setPixmap(pixmap)
404
405    scene.addItem(pixmap_item)
406
407    ruler = RulerItem()
408
409    scene.addItem(ruler)
410
411    ruler.setPos(50,100)
412
413    view = QtWidgets.QGraphicsView(scene)
414    view.show()
415
416    sys.exit(app.exec_())
417
418
419
420if __name__ == '__main__':
421    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_mm (float): The conversion for pixels to millimeters. For example, 10 means 10 
240            pixels-per-mm, meaning the ruler value will show 1 mm 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_mm = None, initial_pos_p1=None, initial_pos_p2=None, relative_origin_position="bottomleft"):
249        super().__init__()
250
251        self.unit = unit
252
253        mm_per_unit = 1.0
254        if "cm" == unit:
255            mm_per_unit = 10.0
256        elif "m" == unit:
257            mm_per_unit = 1000.0
258        elif "in" == unit:
259            mm_per_unit = 25.4
260        elif "ft" == unit:
261            mm_per_unit = 304.8
262        elif "yd" == unit:
263            mm_per_unit = 914.4
264        
265        if "px" == unit:
266            self.px_per_unit = 1.0
267        else:
268            self.px_per_unit = px_per_mm * mm_per_unit
269
270        self._mm_per_unit = mm_per_unit
271
272        self.relative_origin_position = relative_origin_position
273
274        if not initial_pos_p1:
275            initial_pos_p1 = QtCore.QPointF(10,10)
276        if not initial_pos_p2:
277            initial_pos_p2 = QtCore.QPointF(100,200)
278
279        pen = QtGui.QPen()
280        pen.setWidth(2)
281        pen.setCosmetic(True)
282        pen.setColor(QtCore.Qt.white) # setColor also works
283        pen.setCapStyle(QtCore.Qt.SquareCap)
284        pen.setJoinStyle(QtCore.Qt.MiterJoin)
285            
286        brush = QtGui.QBrush()
287        brush.setColor(QtCore.Qt.white)
288        brush.setStyle(QtCore.Qt.SolidPattern)
289
290        brush_black = QtGui.QBrush()
291        brush_black.setColor(QtCore.Qt.black)
292        brush_black.setStyle(QtCore.Qt.SolidPattern)
293
294
295        width = 8
296        height = 8
297        point_topleft = QtCore.QPointF(-width/2, -height/2)
298        point_bottomright = QtCore.QPointF(width/2,height/2)
299        ellipse_rect = QtCore.QRectF(point_topleft, point_bottomright)
300
301        self.ellipse_item1 = CustomItem(ellipse_rect)
302        self.ellipse_item1.setPos(initial_pos_p1)
303        self.ellipse_item1.setBrush(brush_black)
304        self.ellipse_item1.setPen(pen)
305
306
307        text_item = QtWidgets.QGraphicsTextItem("text")
308        text_item.setPos(0,0)
309        font = text_item.font()
310        font.setPointSize(11)
311        text_item.setFont(font)
312        text_item.setDefaultTextColor(QtCore.Qt.white)
313        text_item.setFlags(QtWidgets.QGraphicsItem.ItemIgnoresTransformations) # QtWidgets.QGraphicsItem.ItemIsSelectable
314
315        text_item1 = QtWidgets.QGraphicsTextItem("text")
316        text_item1.setPos(initial_pos_p1)
317        font = text_item1.font()
318        font.setPointSize(10)
319        text_item1.setFont(font)
320        text_item1.setDefaultTextColor(QtCore.Qt.white)
321        text_item1.setFlags(QtWidgets.QGraphicsItem.ItemIgnoresTransformations) # QtWidgets.QGraphicsItem.ItemIsSelectable
322        
323        text_item2 = QtWidgets.QGraphicsTextItem("text")
324        text_item2.setPos(initial_pos_p2)
325        font = text_item2.font()
326        font.setPointSize(10)
327        text_item2.setFont(font)
328        text_item2.setDefaultTextColor(QtCore.Qt.white)
329        text_item2.setFlags(QtWidgets.QGraphicsItem.ItemIgnoresTransformations) # QtWidgets.QGraphicsItem.ItemIsSelectable
330
331        width = 8
332        height = 8
333
334        point_topleft = QtCore.QPointF(-width/2, -height/2)
335        point_bottomright = QtCore.QPointF(width/2, height/2)
336
337        ellipse_rect = QtCore.QRectF(point_topleft, point_bottomright)
338
339        self.ellipse_item2 = CustomItem(ellipse_rect)
340        self.ellipse_item2.setPos(initial_pos_p2)
341        self.ellipse_item2.setBrush(brush_black)
342        self.ellipse_item2.setPen(pen)
343
344        line_item = QtWidgets.QGraphicsLineItem(QtCore.QLineF(40, 40, 80, 80))
345        pen.setStyle(QtCore.Qt.SolidLine)
346        line_item.setPen(pen)
347        self.shadow_line = QtWidgets.QGraphicsDropShadowEffect(blurRadius=4, color=QtGui.QColor(0, 0, 0, 255), xOffset=0, yOffset=0)
348
349        self.ellipse_item1.set_relative_origin_position(self.relative_origin_position)
350        self.ellipse_item2.set_relative_origin_position(self.relative_origin_position)
351
352        self.ellipse_item1.add_line(line_item, True)
353        self.ellipse_item2.add_line(line_item, False)
354        self.ellipse_item1.add_text(text_item)
355        self.ellipse_item2.add_text(text_item)
356        self.ellipse_item1.add_text1(text_item1)
357        self.ellipse_item2.add_text1(text_item1)
358        self.ellipse_item1.add_text2(text_item2)
359        self.ellipse_item2.add_text2(text_item2)
360
361        self.ellipse_item1.set_px_per_unit(self.px_per_unit)
362        self.ellipse_item2.set_px_per_unit(self.px_per_unit)
363        self.ellipse_item1.set_unit(self.unit)
364        self.ellipse_item2.set_unit(self.unit)
365
366        line_item.setParentItem(self)
367        self.ellipse_item1.setParentItem(self)
368        self.ellipse_item2.setParentItem(self)
369        text_item.setParentItem(self)
370        text_item1.setParentItem(self)
371        text_item2.setParentItem(self)
372
373        self.ellipse_item2.refresh_positions()
374        self.ellipse_item1.refresh_positions()
375
376    def set_and_refresh_px_per_unit(self, px_per_unit):
377        """float: Set and refresh units conversion factor (for example, if the conversion is recalculated)."""
378        unit = self.unit
379        if "px" != unit:
380            mm_per_unit = self._mm_per_unit
381            self.px_per_unit = px_per_unit * mm_per_unit
382            self.ellipse_item1.set_px_per_unit(self.px_per_unit)
383            self.ellipse_item2.set_px_per_unit(self.px_per_unit)
384            self.ellipse_item2.refresh_positions()
385            self.ellipse_item1.refresh_positions()
386
387    def set_and_refresh_relative_origin_position(self, relative_origin_position):
388        """str: Set and refresh orientation of coordinate system (for example, if the orientation setting is changed)."""
389        self.relative_origin_position = relative_origin_position
390        self.ellipse_item1.set_relative_origin_position(self.relative_origin_position)
391        self.ellipse_item2.set_relative_origin_position(self.relative_origin_position)
392        self.ellipse_item2.refresh_positions()
393        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_mm (float): The conversion for pixels to millimeters. For example, 10 means 10 pixels-per-mm, meaning the ruler value will show 1 mm 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):
376    def set_and_refresh_px_per_unit(self, px_per_unit):
377        """float: Set and refresh units conversion factor (for example, if the conversion is recalculated)."""
378        unit = self.unit
379        if "px" != unit:
380            mm_per_unit = self._mm_per_unit
381            self.px_per_unit = px_per_unit * mm_per_unit
382            self.ellipse_item1.set_px_per_unit(self.px_per_unit)
383            self.ellipse_item2.set_px_per_unit(self.px_per_unit)
384            self.ellipse_item2.refresh_positions()
385            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):
387    def set_and_refresh_relative_origin_position(self, relative_origin_position):
388        """str: Set and refresh orientation of coordinate system (for example, if the orientation setting is changed)."""
389        self.relative_origin_position = relative_origin_position
390        self.ellipse_item1.set_relative_origin_position(self.relative_origin_position)
391        self.ellipse_item2.set_relative_origin_position(self.relative_origin_position)
392        self.ellipse_item2.refresh_positions()
393        self.ellipse_item1.refresh_positions()

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