October 23, 2024
Chicago 12, Melborne City, USA
python

Qt6 Custom Cursor Offset from Actual Paint Position in QWidget Drawing Application


I’m developing a painting application using PyQt6 where users can draw on images with a custom cursor. I’m experiencing an issue where there’s a consistent offset between where my custom cursor appears and where the actual painting occurs. This offset is particularly noticeable at normal zoom levels but becomes imperceptible when highly zoomed in.

What I’m seeing:

  1. At normal zoom (100%), there’s a gap between the cursor’s center and where lines/points are actually drawn
  2. When zoomed in (e.g., 200%), the painting almost aligns with the cursor.
  3. The line drawing doesn’t start from the cursor’s center point.

My custom cursor creation code:

def create_cursor(self, size):
    cursor_size = max(size * 2, 32)
    cursor_pixmap = QPixmap(cursor_size, cursor_size)
    cursor_pixmap.fill(Qt.GlobalColor.transparent)

    painter = QPainter(cursor_pixmap)
    painter.setRenderHint(QPainter.RenderHint.Antialiasing)

    # Draw outer white circle
    painter.setPen(QPen(Qt.GlobalColor.white, 2))
    painter.drawEllipse(1, 1, cursor_size - 2, cursor_size - 2)

    # Draw inner black circle
    painter.setPen(QPen(Qt.GlobalColor.black, 1))
    painter.drawEllipse(2, 2, cursor_size - 4, cursor_size - 4)

    # Draw brush size indicator
    painter.setPen(QPen(Qt.GlobalColor.red, 1, Qt.PenStyle.DotLine))
    brush_circle_size = min(size, cursor_size - 4)
    offset = (cursor_size - brush_circle_size) // 2
    painter.drawEllipse(offset, offset, brush_circle_size, brush_circle_size)

    # Draw crosshair
    painter.setPen(QPen(Qt.GlobalColor.black, 1))
    mid = cursor_size // 2
    painter.drawLine(mid, 0, mid, cursor_size)
    painter.drawLine(0, mid, cursor_size, mid)

    hotspot = QPoint(cursor_size // 2, cursor_size // 2)
    return QCursor(cursor_pixmap, hotspot.x(), hotspot.y())
def map_to_image(self, pos):
    """Maps window coordinates to image coordinates centering calculations."""

    print("\n=== BEGIN CURSOR POSITION MAPPING DEBUG ===")
    print(f"Raw input position: ({pos.x()}, {pos.y()})")
    print(f"Current zoom factor: {self.zoom_factor}")

    # Get scroll information
    h_scroll = self.scroll_area.horizontalScrollBar()
    v_scroll = self.scroll_area.verticalScrollBar()
    h_scroll_visible = h_scroll.isVisible()
    v_scroll_visible = v_scroll.isVisible()
    scroll_x = h_scroll.value() if h_scroll_visible else 0
    scroll_y = v_scroll.value() if v_scroll_visible else 0

    print(
        f"Scroll visibility - Horizontal: {h_scroll_visible}, Vertical: {v_scroll_visible}"
    )
    print(f"Scroll values - X: {scroll_x}, Y: {scroll_y}")

    # Get image and viewport geometries
    image_pos = self.image_mask_label.pos()
    viewport_pos = self.scroll_area.viewport().pos()
    image_rect = self.image_mask_label.rect()
    viewport_rect = self.scroll_area.viewport().rect()

    # Calculate the actual displayed size of the image (after zoom)
    displayed_width = image_rect.width()  # This is already scaled by zoom
    displayed_height = image_rect.height()  # This is already scaled by zoom

    print(f"Image position: ({image_pos.x()}, {image_pos.y()})")
    print(f"Viewport position: ({viewport_pos.x()}, {viewport_pos.y()})")
    print(
        f"Original Image dimensions: {self.mask_pixmap.width()}x{self.mask_pixmap.height()}"
    )
    print(f"Displayed Image dimensions: {displayed_width}x{displayed_height}")
    print(f"Viewport dimensions: {viewport_rect.width()}x{viewport_rect.height()}")

    # Calculate centering offsets based on displayed size vs viewport
    offset_x = (
        abs(viewport_rect.width() - displayed_width) // 2
        if viewport_rect.width() > displayed_width
        else 0
    )
    offset_y = (
        abs(viewport_rect.height() - displayed_height) // 2
        if viewport_rect.height() > displayed_height
        else 0
    )

    print(f"Centering offsets - X: {offset_x}, Y: {offset_y}")

    # Get cursor information
    cursor = self.cursor()
    hotspot = cursor.hotSpot()
    print(f"Cursor hotspot: ({hotspot.x()}, {hotspot.y()})")
    print(f"Current brush size: {self.brush_size}")

    # Calculate position in image coordinates
    if h_scroll_visible or v_scroll_visible:
        # When scrollbars are visible, adjust for scroll position
        screen_x = pos.x() + scroll_x - image_pos.x()
        screen_y = pos.y() + scroll_y - image_pos.y()
        print(f"Scroll-adjusted position: ({screen_x}, {screen_y})")
    else:
        # When no scrollbars, use centering offset
        screen_x = pos.x() - offset_x - image_pos.x()
        screen_y = pos.y() - offset_y - image_pos.y()
        print(f"Offset-adjusted position: ({screen_x}, {screen_y})")

    # Scale the position based on zoom
    scaled_x = screen_x / self.zoom_factor
    scaled_y = screen_y / self.zoom_factor
    print(f"Zoom-scaled position: ({scaled_x}, {scaled_y})")

    # Apply cursor centering correction
    # Center the brush on the cursor by subtracting half the brush size
    brush_offset = self.brush_size / 2
    final_x = scaled_x - brush_offset
    final_y = scaled_y - brush_offset
    print(f"Brush-centered position: ({final_x}, {final_y})")

    # Ensure coordinates are within image bounds
    final_x = max(0, min(final_x, self.mask_pixmap.width() - 1))
    final_y = max(0, min(final_y, self.mask_pixmap.height() - 1))
    final_pos = QPoint(int(final_x), int(final_y))

    print(f"Final mapped position: ({final_pos.x()}, {final_pos.y()})")
    print(
        f"Image boundaries: width={self.mask_pixmap.width()}, height={self.mask_pixmap.height()}"
    )
    print("=== END CURSOR POSITION MAPPING DEBUG ===\n")

    return final_pos


def draw_point(self, pos):
    if not self.mask_pixmap:
        return

    painter = QPainter(self.mask_pixmap)
    painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)

    # Create a temporary pixmap for the current stroke
    temp_pixmap = QPixmap(self.mask_pixmap.size())
    temp_pixmap.fill(Qt.GlobalColor.transparent)
    temp_painter = QPainter(temp_pixmap)
    temp_painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)

    # Set up the pen and brush
    pen = QPen(
        self.brush_color,
        1,
        Qt.PenStyle.SolidLine,
        Qt.PenCapStyle.RoundCap,
        Qt.PenJoinStyle.RoundJoin,
    )
    temp_painter.setPen(pen)
    temp_painter.setBrush(QBrush(self.brush_color))

    # Draw the stroke
    if self.last_point:
        temp_painter.setPen(
            QPen(
                self.brush_color,
                self.brush_size,
                Qt.PenStyle.SolidLine,
                Qt.PenCapStyle.RoundCap,
                Qt.PenJoinStyle.RoundJoin,
            )
        )
        temp_painter.drawLine(self.last_point, pos)
    else:
        diameter = self.brush_size
        top_left = QPoint(pos.x() - diameter // 2, pos.y() - diameter // 2)
        temp_painter.drawEllipse(top_left.x(), top_left.y(), diameter, diameter)

    temp_painter.end()

    # Apply the stroke to the mask
    if self.eraser_button.isChecked():
        painter.setCompositionMode(
            QPainter.CompositionMode.CompositionMode_DestinationOut
        )
    else:
        painter.setCompositionMode(
            QPainter.CompositionMode.CompositionMode_SourceOver
        )

    painter.drawPixmap(0, 0, temp_pixmap)
    painter.end()

    self.last_point = pos
    self.update_display()

Tracing With Zoom

Tracing with No Zoom

What I’ve tried:

  • Adjusting the hotspot position in the cursor creation
  • Modifying the brush offset calculations
  • The current coordinate trasnformation works best, but are not accurate.
  • Debugging cursor and drawing positions (both appear correct in isolation)

I suspect this might be related to how Qt handles cursor positioning versus mouse event coordinates, but I can’t figure out why the behavior changes with zoom level.
Any ideas what could be causing this offset and why it behaves differently at different zoom levels?

Environment:

Python 3.10
PyQt6
Windows 11



You need to sign in to view this answers

Leave feedback about this

  • Quality
  • Price
  • Service

PROS

+
Add Field

CONS

+
Add Field
Choose Image
Choose Video