1.7 PySide6 三栏工作台:QPainter 渲染、坐标契约与框选/涂抹交互

1.7 PySide6 三栏工作台:QPainter 渲染、坐标契约与框选/涂抹交互

清印 ClearMark 系列第 7 篇 · 共 12 篇

前六篇我们都在讲算法------PDF 内容流解析、矢量检测、扫描件像素修复、图像聚类。这一篇开始进入 UI 层。

UI 层是用户直接接触的部分,也是踩坑最多的一层。这一篇讲 MainWindow 三栏布局、PreviewWidget 的 QPainter 渲染、文档坐标与屏幕坐标的转换契约、鼠标框选/画笔涂抹交互。每个点都有真实调试故事。

一、MainWindow 三栏布局

ui/main_window.py 是主窗口。整体布局遵循"左导航 + 中工作区 + 右详情"的经典三栏:

python 复制代码
class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle(APP_NAME)
        self.setMinimumSize(820, 540)
        ...
        self._setup_ui()
        self._setup_toolbar()
        self._setup_statusbar()
        self._connect_signals()

_setup_ui 用 QSplitter 实现三栏:

python 复制代码
def _setup_ui(self):
    central = QWidget()
    self.setCentralWidget(central)
    layout = QHBoxLayout(central)
    layout.setContentsMargins(0, 0, 0, 0)
    layout.setSpacing(0)

    splitter = QSplitter(Qt.Horizontal)
    # 左栏:文件/任务列表
    self.file_list = QListWidget()
    self.file_list.setMinimumWidth(180)
    self.file_list.setMaximumWidth(360)
    # 中栏:预览工作区
    self.preview = PreviewWidget()
    # 右栏:检测结果面板
    self.result_panel = ResultPanel()
    self.result_panel.setMinimumWidth(220)

    splitter.addWidget(self.file_list)
    splitter.addWidget(self.preview)
    splitter.addWidget(self.result_panel)
    # 初始宽度比例 1:4:1.5
    splitter.setStretchFactor(0, 1)
    splitter.setStretchFactor(1, 4)
    splitter.setStretchFactor(2, 2)
    splitter.setSizes([220, 800, 280])

    layout.addWidget(splitter)

几个设计取舍:

1.1 setMinimumSize(820, 540)

主窗口最小尺寸 820×540。这个数字不是随便写的------左栏至少 180px 显示文件名,右栏至少 220px 显示候选列表,中栏至少 420px 显示预览,加起来正好 820。竖向 540 是工具栏(40) + 中栏预览(440) + 状态栏(30) + 边距(30)。

太小的窗口会让三栏挤成一团,用户体验灾难。所以必须设最小尺寸,阻止用户拖太小。

1.2 setStretchFactor 与 setSizes 的区别

setStretchFactor 控制窗口放大时各栏按比例分配多余空间,setSizes 控制初始尺寸。两者要配合------只设 setSizes 的话,用户拖大窗口后中栏不会跟着放大;只设 setStretchFactor 的话,初始尺寸是默认的,可能太挤。

我们的设置:左栏拉伸因子 1,中栏 4,右栏 2。窗口放大时,多出的空间 4/7 给中栏,2/7 给右栏,1/7 给左栏------中栏优先放大,因为预览是最重要的。

1.3 操作按钮固定在右栏底部

python 复制代码
# 右栏:检测结果面板
self.result_panel = ResultPanel()

ResultPanel 内部布局:

复制代码
┌──────────────────────┐
│ 候选列表(可滚动)    │
│                      │
│ ─── 分隔线 ───       │
│ □ 候选1 [高置信度]   │
│ □ 候选2 [中置信度]   │
│ □ 候选3 [低置信度]   │
│                      │
├──────────────────────┤
│ [全选] [全不选]      │ ← 固定底部
│ [移除水印并保存]     │
└──────────────────────┘

操作按钮(全选/全不选/移除并保存)固定在底部,永不随列表滚动消失。这是 V2.0 改了一版才确定的------第一版把按钮放在列表头部,结果列表一长按钮就被滚到屏幕外,用户找不到移除按钮。

实现技巧:用 QVBoxLayout,候选列表设 setSizePolicy(Expanding, Expanding) 占据所有可用空间,按钮区设 setSizePolicy(Preferred, Fixed) 固定高度。这样列表自动滚动,按钮永远在底部可见。

二、PreviewWidget:QPainter 渲染核心

ui/preview_widget.py 是整个 UI 最复杂的部分。它要做的事:

  1. 渲染 PDF/图片页面到屏幕(QPainter.drawPixmap)。
  2. 在页面上叠加红框标记检测到的水印候选。
  3. 显示用户框选/涂抹轨迹。
  4. 支持鼠标交互:左键框选、画笔涂抹、橡皮擦除。
  5. 支持对比模式:左右并排显示原图与处理后图。
  6. 翻页、缩放、适应窗口。

这么多事必须用 QPainter 手绘,不能用 QLabel + QPixmap 的简单方案------后者无法精细控制 z-order 和叠加层。

2.1 整体类设计

python 复制代码
class PreviewWidget(QWidget):
    """预览工作区

    综合渲染:页面 pixmap + 候选红框 + 用户标记 + 框选/画笔轨迹 + 对比双图
    """

    def __init__(self, parent=None):
        super().__init__(parent)
        self.setMouseTracking(True)
        self.setAcceptDrops(True)
        self.setFocusPolicy(Qt.StrongFocus)

        # 数据
        self._pixmap: Optional[QPixmap] = None
        self._page_rect = QRectF(0, 0, 0, 0)  # 文档坐标的页面矩形
        self._render_scale: float = 1.5      # 渲染缩放(文档→屏幕像素)
        self._zoom: float = 1.0              # 用户缩放(屏幕像素额外放大)

        # 候选与标记
        self._candidates: List[WatermarkCandidate] = []
        self._marks: List[UserMark] = []
        self._current_mark: Optional[UserMark] = None
        self._tool = 'select'  # 'select' | 'rect' | 'brush' | 'eraser' | 'lasso'

        # 对比模式
        self._compare_mode: bool = False
        self._compare_pixmap: Optional[QPixmap] = None

        # 鼠标状态
        self._mouse_pos: Optional[QPointF] = None
        self._drag_start: Optional[QPointF] = None

几个关键成员:

  • _pixmap:渲染后的页面 pixmap(屏幕像素)。
  • _page_rect:文档坐标的页面矩形(PDF 点或原图像素)。
  • _render_scale:渲染缩放,文档单位 → 屏幕像素的比例。
  • _zoom:用户缩放,屏幕像素的额外放大。
  • _candidates:检测到的水印候选列表。
  • _marks:用户已完成的标记列表。
  • _current_mark:正在画的标记。
  • _compare_mode:对比模式开关。
  • _compare_pixmap:对比用的处理后 pixmap。

2.2 _render_scale 与 _zoom 的区别

这是坐标系统最关键的设计:

  • _render_scale :调用 processor.render_page(zoom=1.5) 时传的 1.5。文档单位 1pt 对应 1.5 个屏幕像素。这个值在调用 render_page 后被固定。
  • _zoom :用户在 UI 上按 Ctrl+滚轮时的额外放大。这不重新渲染页面(避免重渲染开销),只是在 paintEvent 里把 pixmap 进一步放大显示。

完整缩放链:

复制代码
文档坐标 (pt) ──× render_scale (1.5) ──→ 屏幕像素 ──× _zoom (1.0) ──→ 显示像素

为什么要分两层?因为 render_page 开销大(PyMuPDF 要光栅化整页),频繁调用会卡。把"用户缩放"独立出来,只缩放已渲染的 pixmap,开销小(GPU 加速的图像缩放)。

代价是用户放大太多后画面会模糊(pixmap 分辨率不够)。所以我们设了上限------_zoom 最大 5.0,超过会提示用户用 Ctrl+F 适应窗口。

三、paintEvent:分层渲染

paintEvent 是 QPainter 渲染的核心:

python 复制代码
def paintEvent(self, event: QPaintEvent):
    painter = QPainter(self)
    painter.setRenderHint(QPainter.Antialiasing)
    painter.setRenderHint(QPainter.SmoothPixmapTransform)

    # 1. 背景
    painter.fillRect(self.rect(), QColor(40, 40, 40))

    if self._pixmap is None or self._pixmap.isNull():
        self._draw_placeholder(painter)
        return

    # 2. 计算显示矩形
    pix_size = self._pixmap.size()
    zoomed_w = int(pix_size.width() * self._zoom)
    zoomed_h = int(pix_size.height() * self._zoom)
    # 居中
    x = (self.width() - zoomed_w) // 2
    y = (self.height() - zoomed_h) // 2
    if x < 0: x = 0
    if y < 0: y = 0

    target_rect = QRectF(x, y, zoomed_w, zoomed_h)
    source_rect = QRectF(0, 0, pix_size.width(), pix_size.height())

    # 3. 绘制页面 pixmap(必须用三参数版本!)
    painter.drawPixmap(target_rect, self._pixmap, source_rect)

    # 4. 候选红框(文档坐标 → 屏幕坐标 → 绘制)
    if not self._compare_mode:
        self._draw_candidates(painter, target_rect)

    # 5. 用户标记
    self._draw_marks(painter, target_rect)

    # 6. 当前正在画的标记
    if self._current_mark:
        self._draw_mark(painter, self._current_mark, target_rect)

    # 7. 对比模式:右半边画处理后图
    if self._compare_mode and self._compare_pixmap:
        self._draw_compare(painter, target_rect)

3.1 drawPixmap 必须用三参数版本

注释里强调的"必须用三参数版本"是踩过的坑:

python 复制代码
# 正确写法(三参数)
painter.drawPixmap(target_rect, self._pixmap, source_rect)

# 错误写法(两参数)
# painter.drawPixmap(target_rect, self._pixmap)  # 不存在!会调用别的重载或崩溃

PySide6 的 QPainter.drawPixmap 有多个重载:

  • drawPixmap(QRectF, QPixmap, QRectF) --- 把源矩形画到目标矩形,支持缩放。
  • drawPixmap(QPoint, QPixmap) --- 把 pixmap 画到指定位置,不缩放。
  • drawPixmap(QRectF, QPixmap) --- 不存在!写出来要么调用错误重载(图像不缩放),要么直接报错。

我们的场景需要缩放(用户缩放、适应窗口),必须用三参数版本。第一版写了两参数,结果缩放功能完全失效------pixmap 始终原尺寸显示,用户按 Ctrl+滚轮没反应。

3.2 三参数的语义

python 复制代码
target_rect = QRectF(x, y, zoomed_w, zoomed_h)  # 屏幕上的目标矩形
source_rect = QRectF(0, 0, pix_size.width(), pix_size.height())  # pixmap 的源矩形
painter.drawPixmap(target_rect, self._pixmap, source_rect)

含义:把 pixmap 的 source_rect 区域(通常是整个 pixmap)画到屏幕的 target_rect 区域。如果两者尺寸不同,自动缩放。

这种写法的好处:

  • 可以只画 pixmap 的一部分(修改 source_rect),实现局部放大。
  • 可以指定目标尺寸(修改 target_rect),实现用户缩放。
  • 避免裁剪问题:Qt 会自动按 target_rect 裁剪到 painter 的 clip region。

四、坐标契约:文档 ↔ 屏幕

整个 UI 最绕的地方是 坐标转换。水印候选的 bbox 是文档坐标(PDF 点或原图像素),但要在屏幕上画红框,必须转到屏幕坐标。

4.1 文档坐标 → 屏幕坐标

python 复制代码
def _doc_to_screen(self, doc_point: QPointF, target_rect: QRectF) -> QPointF:
    """文档坐标 → 屏幕坐标"""
    if self._page_rect.width() == 0 or self._page_rect.height() == 0:
        return QPointF(0, 0)
    sx = target_rect.x() + (doc_point.x() / self._page_rect.width()) * target_rect.width()
    sy = target_rect.y() + (doc_point.y() / self._page_rect.height()) * target_rect.height()
    return QPointF(sx, sy)

线性变换:

复制代码
screen_x = target_x + (doc_x / doc_width) * target_width

target_rect 是当前 pixmap 在屏幕上的显示矩形(来自 paintEvent 计算)。page_rect 是文档坐标的页面矩形(PDF 的 page.rect 或原图尺寸)。

4.2 屏幕坐标 → 文档坐标

鼠标事件回调用:

python 复制代码
def _screen_to_doc(self, screen_point: QPointF, target_rect: QRectF) -> QPointF:
    """屏幕坐标 → 文档坐标"""
    if target_rect.width() == 0 or target_rect.height() == 0:
        return QPointF(0, 0)
    dx = (screen_point.x() - target_rect.x()) / target_rect.width() * self._page_rect.width()
    dy = (screen_point.y() - target_rect.y()) / target_rect.height() * self._page_rect.height()
    return QPointF(dx, dy)

逆向变换,用于把鼠标点击位置转回文档坐标。

4.3 为什么标记存文档坐标

UserMark.bbox 存的是文档坐标,不是屏幕坐标。原因:

  1. 窗口大小变化时坐标不变。用户拖大窗口,pixmap 显示尺寸变了,但水印的文档位置没变,红框应该跟着 pixmap 一起缩放。如果存屏幕坐标,窗口一变红框就错位。
  2. 缩放时坐标不变。用户按 Ctrl+滚轮放大,pixmap 变大,但水印的文档位置没变,红框跟着 pixmap 缩放即可。
  3. 传给处理器时无需转换 。处理器接收的是文档坐标,与 WatermarkCandidate.bbox 同坐标系,可以直接做几何运算。

4.4 扫描件的特殊坐标转换

PdfScannedProcessor 的候选 bbox 是 内嵌图像素 ,不是页面点。直接画到屏幕上会错位。所以它重写了基类的 candidate_page_bbox:

python 复制代码
def candidate_page_bbox(self, cand) -> tuple:
    """内嵌图像素候选 bbox → 页面点坐标(供 UI 在渲染图上定位)"""
    info = next((i for i in infos if i['xref'] == cand.xref), None)
    img_w, img_h = info['width'], info['height']
    px0, py0, px1, py1 = info['bbox']
    place_w = px1 - px0
    place_h = py1 - py0
    x0 = px0 + cand.bbox[0] / img_w * place_w
    y0 = py0 + cand.bbox[1] / img_h * place_h
    x1 = px0 + cand.bbox[2] / img_w * place_w
    y1 = py0 + cand.bbox[3] / img_h * place_h
    return (x0, y0, x1, y1)

把内嵌图像素坐标按"内嵌图在页面上的放置位置与缩放"换算回页面点。这是扫描件水印红框能正确显示的关键。

UI 层只需要调 processor.candidate_page_bbox(cand),不感知背后的坐标系差异。这是策略模式的好处。

五、鼠标交互:框选/画笔/套索

5.1 工具切换

PreviewWidget 支持 5 种工具:

python 复制代码
self._tool = 'select'  # 'select' | 'rect' | 'brush' | 'eraser' | 'lasso'

工具切换由 MainWindow 的工具栏触发:

python 复制代码
def set_tool(self, tool: str):
    self._tool = tool
    if tool == 'select':
        self.setCursor(Qt.ArrowCursor)
    elif tool == 'rect':
        self.setCursor(Qt.CrossCursor)
    elif tool in ('brush', 'eraser'):
        self.setCursor(Qt.BlankCursor)  # 自定义画笔光标
    elif tool == 'lasso':
        self.setCursor(Qt.CrossCursor)
    self.update()

不同工具改光标样式,给用户视觉反馈。

5.2 鼠标按下:开始标记

python 复制代码
def mousePressEvent(self, event: QMouseEvent):
    if event.button() != Qt.LeftButton:
        return
    target_rect = self._current_target_rect()
    if target_rect is None:
        return
    doc_pos = self._screen_to_doc(event.position(), target_rect)

    if self._tool == 'select':
        # 选择工具:点击水印候选切换选中状态
        self._toggle_candidate_by_click(doc_pos)
        return

    # 其他工具:开始一个新标记
    self._drag_start = event.position()
    if self._tool == 'rect':
        self._current_mark = UserMark(
            mark_type=MarkType.RECT,
            page_index=self._current_page,
            bbox=(doc_pos.x(), doc_pos.y(), doc_pos.x(), doc_pos.y()),
            points=[],
        )
    elif self._tool in ('brush', 'eraser'):
        self._current_mark = UserMark(
            mark_type=MarkType.BRUSH if self._tool == 'brush' else MarkType.ERASER,
            page_index=self._current_page,
            points=[(doc_pos.x(), doc_pos.y())],
            brush_radius=self._brush_radius / self._render_scale,
        )
    elif self._tool == 'lasso':
        self._current_mark = UserMark(
            mark_type=MarkType.LASSO,
            page_index=self._current_page,
            points=[(doc_pos.x(), doc_pos.y())],
        )
    self.update()

关键点:

  • _screen_to_doc 把鼠标位置转到文档坐标,标记存文档坐标。
  • brush_radius 除以 _render_scale------画笔半径用户在屏幕上看到的是 10 像素,但存到文档坐标要换算成文档单位。否则缩放后画笔粗细会变。
  • select 工具特殊:点击不创建标记,而是切换水印候选的选中状态。

5.3 鼠标移动:更新标记

python 复制代码
def mouseMoveEvent(self, event: QMouseEvent):
    self._mouse_pos = event.position()
    if not self._current_mark:
        self.update()
        return
    target_rect = self._current_target_rect()
    if target_rect is None:
        return
    doc_pos = self._screen_to_doc(event.position(), target_rect)

    if self._current_mark.mark_type == MarkType.RECT:
        x0 = min(self._current_mark.bbox[0], doc_pos.x())
        y0 = min(self._current_mark.bbox[1], doc_pos.y())
        x1 = max(self._current_mark.bbox[0], doc_pos.x())
        y1 = max(self._current_mark.bbox[1], doc_pos.y())
        self._current_mark.bbox = (x0, y0, x1, y1)
    elif self._current_mark.mark_type in (MarkType.BRUSH, MarkType.ERASER):
        self._current_mark.points.append((doc_pos.x(), doc_pos.y()))
    elif self._current_mark.mark_type == MarkType.LASSO:
        self._current_mark.points.append((doc_pos.x(), doc_pos.y()))

    self.update()

矩形工具更新 bbox 的 (x1, y1) 角点(取 min/max 防止反向拖拽时坐标错乱)。画笔/套索工具持续追加 points。

5.4 鼠标释放:完成标记

python 复制代码
def mouseReleaseEvent(self, event: QMouseEvent):
    if event.button() != Qt.LeftButton:
        return
    if not self._current_mark:
        return

    # 矩形太小 → 丢弃
    if self._current_mark.mark_type == MarkType.RECT:
        w = abs(self._current_mark.bbox[2] - self._current_mark.bbox[0])
        h = abs(self._current_mark.bbox[3] - self._current_mark.bbox[1])
        if w < 5 or h < 5:
            self._current_mark = None
            self.update()
            return
    # 画笔/套索点太少 → 丢弃
    elif self._current_mark.mark_type in (MarkType.BRUSH, MarkType.ERASER, MarkType.LASSO):
        if len(self._current_mark.points) < 2:
            self._current_mark = None
            self.update()
            return

    self._marks.append(self._current_mark)
    self._current_mark = None
    self.update()
    # 通知主窗口更新
    self.marks_changed.emit(self._marks)

完成标记的过滤:

  • 矩形要求 ≥5×5 文档单位,太小丢弃(防误触)。
  • 画笔/套索要求 ≥2 个点,单击不算误触。

完成后通过信号 marks_changed 通知 MainWindow。

六、绘制候选红框:避免与用户标记叠加

_draw_candidates 把水印候选画成红框:

python 复制代码
def _draw_candidates(self, painter: QPainter, target_rect: QRectF):
    """绘制检测到的水印候选(红框 + 半透明填充)"""
    if not self._candidates:
        return
    pen = QPen(QColor(255, 60, 60), 2)
    painter.setPen(pen)
    for cand in self._candidates:
        # 处理器可能重写了 candidate_page_bbox(扫描件)
        page_bbox = self._processor.candidate_page_bbox(cand) \
            if self._processor else tuple(cand.bbox)
        # 文档坐标 → 屏幕坐标
        x0, y0 = self._doc_to_screen(QPointF(page_bbox[0], page_bbox[1]), target_rect)
        x1, y1 = self._doc_to_screen(QPointF(page_bbox[2], page_bbox[3]), target_rect)
        # 红框
        rect = QRectF(x0, y0, x1 - x0, y1 - y0).normalized()
        # 半透明填充(选中状态不同)
        if cand.selected:
            painter.setBrush(QColor(255, 60, 60, 60))
        else:
            painter.setBrush(QColor(255, 60, 60, 20))
        painter.drawRect(rect)
        # 标签
        painter.setPen(QColor(255, 220, 220))
        painter.drawText(rect.topLeft() + QPointF(2, 12),
                         f'{cand.detail[:30]} [{int(cand.confidence * 100)}%]')

关键设计:

6.1 调用处理器的 candidate_page_bbox

python 复制代码
page_bbox = self._processor.candidate_page_bbox(cand) \
    if self._processor else tuple(cand.bbox)

这是策略模式的精髓------UI 层不感知 PDF 矢量/扫描件/图片的坐标差异,统一调 candidate_page_bbox 拿到页面坐标。处理器自己负责把内部坐标系(页面点/内嵌图像素/原图像素)转换到页面坐标。

6.2 选中状态视觉区分

python 复制代码
if cand.selected:
    painter.setBrush(QColor(255, 60, 60, 60))  # 较深半透明
else:
    painter.setBrush(QColor(255, 60, 60, 20))  # 较浅半透明

选中的候选红框填充更深(alpha=60),未选中更浅(alpha=20)。一眼能看出哪些会被移除。

6.3 对比模式不画候选

python 复制代码
if not self._compare_mode:
    self._draw_candidates(painter, target_rect)

对比模式(左右并排原图与处理后图)时不画候选红框------避免红框遮挡处理后图的视觉效果。用户要看的是"移除效果好不好",不是"水印在哪里"。

七、画笔轨迹绘制:屏幕坐标

画笔轨迹的绘制用屏幕坐标,不是文档坐标:

python 复制代码
def _draw_mark(self, painter: QPainter, mark: UserMark, target_rect: QRectF):
    """绘制一个用户标记"""
    if mark.mark_type == MarkType.RECT:
        # 矩形:文档坐标 → 屏幕坐标
        p0 = self._doc_to_screen(QPointF(mark.bbox[0], mark.bbox[1]), target_rect)
        p1 = self._doc_to_screen(QPointF(mark.bbox[2], mark.bbox[3]), target_rect)
        painter.setPen(QPen(QColor(60, 200, 255), 2, Qt.DashLine))
        painter.setBrush(Qt.NoBrush)
        painter.drawRect(QRectF(p0, p1).normalized())
    elif mark.mark_type in (MarkType.BRUSH, MarkType.ERASER):
        # 画笔:把每个点转到屏幕,连成线
        pen_color = QColor(60, 200, 255) if mark.mark_type == MarkType.BRUSH \
            else QColor(255, 200, 60)
        r = mark.brush_radius * self._render_scale * self._zoom
        painter.setPen(QPen(pen_color, max(1, int(r * 2)), Qt.SolidLine,
                            Qt.RoundCap, Qt.RoundJoin))
        for i in range(len(mark.points) - 1):
            p0 = self._doc_to_screen(QPointF(*mark.points[i]), target_rect)
            p1 = self._doc_to_screen(QPointF(*mark.points[i + 1]), target_rect)
            painter.drawLine(p0, p1)
    elif mark.mark_type == MarkType.LASSO:
        # 套索:闭合多边形
        painter.setPen(QPen(QColor(60, 200, 255), 2, Qt.DashLine))
        painter.setBrush(QColor(60, 200, 255, 30))
        pts = [self._doc_to_screen(QPointF(*p), target_rect) for p in mark.points]
        poly = QPolygonF(pts)
        painter.drawPolygon(poly)

画笔半径的换算:

python 复制代码
r = mark.brush_radius * self._render_scale * self._zoom

mark.brush_radius 是文档单位,× render_scale 转屏幕像素,× _zoom 应用用户缩放。这样画笔在屏幕上的实际粗细随缩放等比变化。

八、QPixmap 空判断的坑

最后讲一个 UI 层的经典坑------QPixmap 真值判断:

python 复制代码
# 错误写法
if self._pixmap:
    # pixmap 可能是空 pixmap 但真值为 True!

# 正确写法
if self._pixmap is not None and not self._pixmap.isNull():
    # 真正确认非空

QPixmap 的 __bool__ 总是返回 True(Python 对象总为真),即使是空 pixmap。空 pixmap 的判断必须用 isNull()。

我们封装了一个工具函数:

python 复制代码
def _pixmap_is_valid(p: Optional[QPixmap]) -> bool:
    return p is not None and not p.isNull()

所有 pixmap 使用前都过这个函数。否则渲染空 pixmap 会让 drawPixmap 抛警告或崩溃。

九、本篇总结

这一篇我们讲了 UI 层的核心:

  1. MainWindow 三栏布局 :QSplitter + 拉伸因子 + 初始尺寸 + 最小尺寸 820×540。
  2. 操作按钮固定底部 :ResultPanel 用 QVBoxLayout + SizePolicy 让按钮永远可见。
  3. PreviewWidget QPainter 渲染:分层绘制(背景 → pixmap → 候选红框 → 用户标记 → 对比图)。
  4. drawPixmap 三参数版本 :drawPixmap(target_rect, pixmap, source_rect) 是唯一支持缩放的版本,两参数版本不存在或行为不同。
  5. 坐标契约 :标记存文档坐标,渲染时转屏幕坐标;扫描件候选通过 candidate_page_bbox 换算到页面坐标。
  6. _render_scale vs _zoom:渲染缩放(固定)+ 用户缩放(动态),避免频繁重渲染。
  7. 五种工具:select/rect/brush/eraso/lasso,鼠标按下/移动/释放三段式交互。
  8. 画笔半径换算 :brush_radius × render_scale × zoom,保证缩放时粗细等比变化。
  9. 候选红框选中状态视觉区分:alpha=60 vs alpha=20。
  10. QPixmap 空判断 :必须 is not None and not isNull(),不能用真值判断。

下一篇我们继续 UI 层,讲 前后对比模式 和 多分辨率图标系统------前者让用户直观看到移除效果,后者保证任务栏图标在高 DPI 屏幕上清晰显示。

📥 完整源码与可执行程序已上传 CSDN 资源,搜索"清印 ClearMark 智能文档去水印工作台"。本系列代码片段对应文件 ui/main_window.py(约 800 行)和 ui/preview_widget.py(约 500 行)。

相关推荐
沫璃染墨1 天前
《Qt从零入门系列(十二):Qt文件操作详解——从QFile读写到QFileInfo与记事本实战》
开发语言·网络·c++·qt·交互·信号处理·文件
一条大祥脚1 天前
26 ICPC上海 交互|二分|重心|构造|二分图|贪心|SOSDP|三进制枚举|线性基|字典序贪心|DFS|分块|单侧递归线段树
算法·深度优先·交互·分块·icpc上海站·单侧递归线段树
HwJack202 天前
【共创稿事节】HarmonyOS 7空间交互综述:视线、头部、手势的协同
数码相机·交互·harmonyos
HwJack202 天前
【共创稿事节】HarmonyOS 7手势识别交互:空中手势的语义与容错设计
人工智能·深度学习·华为·交互·harmonyos
小师兄吃牛肉2 天前
C++ 与 Lua 的协程交互:从原理到实战
c++·lua·交互
兰亭妙微UI设计公司3 天前
兰亭妙微UI设计公司 蚂蚁阿福APP拆解:对话式交互如何重构就医全流程?
重构·交互
新新学长搞科研5 天前
【往届稳定EI+scopus检索】第三届人工智能、数字媒体技术与交互设计国际学术会议(ICADI 2026)
人工智能·交互·媒体
平头哥~5 天前
从文化 IP 解读到伴手礼定制,文创设计师智能体背后是 端到端具身交互智能落地
网络协议·tcp/ip·交互
大侠归来5 天前
C++ 与 Lua 的协程交互:从原理到实战
c++·lua·交互