You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

PyQt6绘图应用:自定义光标与实际绘制位置偏移问题排查

自定义光标与绘制位置偏移问题(PyQt6绘图应用)

问题背景

我用PyQt6开发绘图应用,用户可通过自定义光标在图像上绘制,但自定义光标显示位置和实际绘制位置始终存在偏移——100%缩放时偏移明显,高缩放比例(如200%)下几乎不可见。

现象说明

  • 100%缩放时,光标中心与绘制的线条/点之间存在间隙
  • 200%缩放时,绘制位置几乎与光标对齐
  • 线条并非从光标中心起始

相关代码

自定义光标创建代码

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()

已尝试的解决方法

  • 调整光标创建时的hotspot位置
  • 修改笔刷偏移计算逻辑
  • 当前坐标转换效果最优,但仍不准确
  • 调试光标与绘制位置(两者单独查看均显示正确)

疑问

怀疑问题与Qt处理光标定位和鼠标事件坐标的方式有关,但无法理解为何偏移情况会随缩放比例变化。请问可能是什么原因导致该偏移,以及为何其表现会随缩放级别不同而改变?

环境信息

  • Python 3.10
  • PyQt6
  • Windows 11

问题原因分析

  1. 双重偏移叠加:map_to_image中已经减去了笔刷半尺寸的偏移,而draw_point的单点绘制逻辑里又再次计算了笔刷左上角的偏移,相当于做了两次中心偏移,直接导致绘制位置偏离光标热点。
  2. 精度丢失:map_to_image中将浮点坐标直接取整为整数QPoint,100%缩放时这个取整误差会直接反映在绘制结果上;高缩放比例下,缩放后的坐标数值更大,取整误差占比极小,因此偏移不明显。
  3. 坐标基准错误:鼠标事件的pos()默认是接收事件部件的局部坐标,若你传入的是全局坐标却用局部坐标的逻辑计算,会导致映射偏差。

解决方法

1. 移除重复偏移逻辑

删除map_to_image中的笔刷偏移代码,将绘制偏移统一放在draw_point中:

# 移除map_to_image内以下代码
# brush_offset = self.brush_size / 2
# final_x = scaled_x - brush_offset
# final_y = scaled_y - brush_offset

2. 保留浮点精度

修改map_to_image返回浮点坐标,避免过早取整:

# 修改map_to_image的返回部分
final_x = max(0.0, min(scaled_x, self.mask_pixmap.width() - 1.0))
final_y = max(0.0, min(scaled_y, self.mask_pixmap.height() - 1.0))
return QPointF(final_x, final_y)

同时更新draw_point以支持浮点坐标绘制:

def draw_point(self, pos: QPointF):
    # ... 其他代码不变
    if not self.last_point:
        diameter = self.brush_size
        # 使用浮点坐标计算椭圆中心
        temp_painter.drawEllipse(pos, diameter / 2, diameter / 2)
    # ... 其他代码不变

3. 统一坐标基准

确保传入map_to_image的是部件局部坐标,若使用全局坐标需先转换:

# 鼠标事件处理中转换坐标
local_pos = self.image_mask_label.mapFromGlobal(event.globalPos())
image_pos = self.map_to_image(local_pos)

内容的提问来源于stack exchange,提问作者Kinjalk Parth

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.17 02:22:03