1.12 清印 ClearMark 避坑实录:drawPixmap 崩溃、update_stream 黑块、多次移除白板
这是「清印 ClearMark 智能文档去水印工作台」系列专栏的收官篇------第 12 篇。
前面十一篇我按模块讲了架构、解析、检测、移除、UI、批量、规则库、跨平台。每一篇里都零散提过一些踩坑,但当时的重点是讲原理和实现,坑只是顺带提。
这一篇不一样,专门讲坑。清印 ClearMark 从立项到 V2.0 稳定,我踩过二十多个值得记录的坑。每个坑都按「症状 → 错误判断 → 真正原因 → 修正」四段式展开,这样后来人遇到同样症状能快速对号入座。
我把坑按主题分了八组:UI 渲染、线程并发、坐标系统、PDF 操作、状态管理、启动字体、备份机制、检测逻辑。
一、UI 渲染类
坑 1:QPainter.drawPixmap 缺第三个参数导致渲染失败
症状:预览区显示空白,调试时 painter 调用没抛异常,但 pixmap 就是画不出来。
错误判断 :怀疑 pixmap 加载失败。打印 pixmap.isNull() 返回 False,确认加载没问题。又怀疑 painter 没激活,检查了 begin/end 也正常。
真正原因 :调用形式写成了 painter.drawPixmap(target_rect, pixmap),二参数重载。PySide6 里 QPainter 没有 drawPixmap(QRectF, QPixmap) 这个签名,传两个参数会被错误解析,原生层要么静默失败要么画到不可预测的位置。C++ Qt 里这个重载是合法的,PySide6 绑定丢了。
修正:必须传三个参数------目标矩形、pixmap、源矩形:
python
full_src = QRectF(0, 0, self._pixmap.width(), self._pixmap.height())
painter.drawPixmap(draw_rect, self._pixmap, full_src)
这条坑的代价是浪费了我大半天,因为静态检查和运行时都不报错。后来在 preview_widget.py 里我加了详细注释:
python
# 必须显式传源矩形:QPainter 没有 drawPixmap(QRectF, QPixmap)
# 二参重载,缺省第三个参数会被 PySide6 错误解析导致原生崩溃/绘制失败
坑 2:对比模式下 drawPixmap 参数错误导致图片压缩和叠加
症状:移除后开「前后对比」,左右两张图叠加在一起,且都被横向压扁了。
错误判断:怀疑布局算的宽度不对,调了半天 splitter 比例没用。
真正原因 :对比模式调用 drawPixmap 时把目标矩形和源矩形写反了。源矩形传成了目标矩形的尺寸(比如 QRectF(0, 0, pw, ph),pw=显示宽度 400),但 pixmap 真实宽度可能是 2480。drawPixmap 会按源矩形的尺寸去裁原图,导致只画了原图左上角的一小块,又被拉伸到目标矩形里,看起来就是压缩 + 叠加。
修正:源矩形必须用 pixmap 自身的像素尺寸:
python
clean_full = QRectF(0, 0, self._clean_pixmap.width(),
self._clean_pixmap.height())
painter.drawPixmap(QRectF(group_cx, cy, pw, ph),
self._pixmap, orig_full)
painter.drawPixmap(QRectF(right_x, cy, pw, ph),
self._clean_pixmap, clean_full)
目标矩形决定显示位置和大小,源矩形决定从原图取哪一块。两个不能混。
坑 3:对比模式窗口宽度不够导致两侧被裁
症状:开对比模式后窗口还是原来 820 宽,两张图左右都被裁掉一截。
错误判断:以为 splitter 没设宽,调 setSizes 没用。
真正原因:进入对比模式时只 hide 了左右两栏,没主动把窗口宽度撑开。窗口默认宽度是按单图模式算的,进对比模式后中央区还是那么宽,画两张图就被裁了。
修正:进入对比模式时按当前预览宽度 ×2 + 间隙重新算窗口宽度:
python
new_width = int(pw * 2 + 28) # 两张图 + 中间分割线间隙
self.resize(max(self.width(), new_width), self.height())
主动撑宽窗口,关闭对比模式时再 restore 原来的尺寸。
坑 4:多分辨率图标只放一两张导致 4K 任务栏糊
症状:在 4K 屏 Windows 11 上任务栏图标糊成一坨,标题栏图标也模糊。
错误判断:以为是 .ico 文件分辨率不够,重新生成 .ico 没改善。
真正原因:Qt 任务栏图标在不同 DPI 下会请求不同尺寸。我只放了 32×32 一张 PNG,Qt 在 200% 缩放下请求 64×64 时找不到,只能拿 32 的放大,糊。
修正:生成 8 档分辨率 PNG(16/24/32/48/64/128/256/512),加进 QIcon 让 Qt 自行选:
python
_ICON_SIZES = [16, 24, 32, 48, 64, 128, 256, 512]
for size in _ICON_SIZES:
png = _find_icon_png(size)
if png:
pix = QPixmap(png)
if not pix.isNull():
icon.addPixmap(pix)
加完所有尺寸后,4K 任务栏、高 DPI 标题栏、文件管理器缩略图都清晰。SVG 兜底,找不到 PNG 时用 SVG 直接渲染。
坑 5:QPixmap 真值判断在 PySide6 下不可靠
症状 :判断 if pixmap: 想在加载失败时跳过绘制,结果加载失败时判断为 True,绘制后崩溃。
错误判断 :以为 PySide6 的 QPixmap 实现了 __bool__,正常判断没问题。
真正原因 :PySide6 的 QPixmap 没有显式定义 __bool__,Python 调用 if pixmap: 走默认对象真值判断(非 None 即真)。加载失败的空 pixmap 也是非 None,被当成有效图传给 drawPixmap,原生层崩。
修正 :必须用 isNull():
python
if not pix.isNull():
icon.addPixmap(pix)
所有 QPixmap 和 QImage 判空都改用 isNull(),不能用 if pixmap:。
二、线程并发类
坑 6:QThread 在运行时被销毁导致核心转储
症状 :用户在检测过程中关窗口,程序直接 核心转储 (core dumped),无任何异常信息。
错误判断:以为是 PyMuPDF 在多线程下不安全,加锁没用。
真正原因 :QThread 实例在 Python 端被回收,但底层线程还在跑。Qt 的 QThread 析构时如果线程还在 running,会触发 QThread: Destroyed while thread is still running,进程崩溃。
修正:closeEvent 里显式 cancel + wait + terminate 兜底:
python
def closeEvent(self, event):
workers = [self.detect_worker, self.remove_worker,
self.batch_detect_worker, self.batch_remove_worker]
for w in workers:
if w is not None and w.isRunning():
w.cancel()
w.wait(3000)
if w.isRunning():
w.terminate()
event.accept()
cancel 是业务层的协作式取消(worker 循环里检查 _cancel_flag),wait 等业务层优雅退出,terminate 是 Qt 层的强制终止,最后兜底。三道防线保证关窗口不崩。
坑 7:批量检测无运行守卫导致重复触发崩溃
症状:用户在批量检测过程中连续点「全部检测」按钮,第二次点击后程序崩溃。
错误判断:以为加 button.setEnabled(False) 就够了。但快捷键 Ctrl+Shift+D 不经过按钮 enabled 状态,照样能触发。
真正原因 :worker 还在跑的时候再次 start,QThread 不允许同一个实例重复 start,会报 QThread::start: Thread already running,但更严重的是业务层 _worker 引用被覆盖,前一个 worker 失去引用被 GC,触发坑 6 的核心转储。
修正 :引入 _workerBusy 守卫:
python
def _onBatchDetect(self):
if self._workerBusy:
return
self._workerBusy = True
...启动 worker...
self._workerBusy = False # 在 finished 信号里复位
所有进入批量操作的入口都先检查 _workerBusy,重复触发直接 return。同时按钮 enabled 还是按 finished 信号复位,但即使绕过按钮(快捷键、菜单)也进不来。
三、坐标系统类
坑 8:屏幕像素坐标直接用于 PDF 操作导致定位错误
症状:用户在预览区框选水印区域,框选时位置看起来对的,但点检测后红框位置全偏移,PDF 操作时擦除的是错误位置。
错误判断:以为 QPainter 的绘制坐标系和 PDF 操作坐标系一致,调试了好久才意识到问题。
真正原因:预览区有缩放(比如 1.5x),用户鼠标事件的坐标是屏幕渲染后的像素坐标,但 PDF 操作要用 page points(PDF 文档坐标)。两者之间差一个缩放系数和一个 offset。
修正:所有用户标记(矩形框、涂抹轨迹)存进 UserMark 时要换算到文档坐标:
python
# 屏幕坐标 → 文档坐标
doc_x = (screen_x - offset_x) / scale
doc_y = (screen_y - offset_y) / scale
mark = UserMark(x=doc_x, y=doc_y, ...)
渲染红框时再换算回去。检测和移除时直接用文档坐标,不参与屏幕渲染。文档坐标是单一事实来源,屏幕坐标只用于显示。
四、PDF 操作类
坑 9:doc.update_stream 替换 JPEG 字节流导致页面损坏
症状:扫描件移除水印后输出 PDF,打开看整页黑块或者花屏。
错误判断:以为是我修复后的图像数据有问题,调试了好久 OpenCV 修复结果,发现修复结果正常。
真正原因 :我用了 self.doc.update_stream(xref, new_jpeg_bytes) 替换图像字节流,但 XObject 字典里的 /Filter 还是原图的(比如 FlateDecode),而新字节是 JPEG(DCTDecode),格式不匹配,解码器按 FlateDecode 解 JPEG 字节必然黑块。
修正 :改用 page.replace_image:
python
def _replace_image(self, xref: int, bgr: np.ndarray):
"""用新图替换 xref 引用的图像 XObject
必须使用 page.replace_image:它会同步更新 /Filter、/ColorSpace、
/Width/Height 等整个 XObject 字典。
"""
...
self._doc[page_idx].replace_image(xref, stream=data)
page.replace_image 是 PyMuPDF 的高层 API,会按新图自动设置 /Filter、/ColorSpace、/Width、/Height、/BitsPerComponent,保证字典和字节流匹配。这是写扫描件修复的必备知识。
坑 10:移除后不重载原始文件导致第二次移除白板
症状:第一次点「移除并保存」输出正常,第二次点输出白板 PDF。
错误判断:怀疑是输出文件命名冲突,改了命名规则没用。又怀疑 PyMuPDF 的 save 出问题,调试了好久。
真正原因:移除是 in-place 修改 self.doc 的,第一次移除后 self.doc 已经是被改过的版本(水印被擦除)。第二次移除时基于这个改过的 doc 再擦,但水印已经不在了,擦的是空,且某些清理操作(XObject 清空)在已清空状态下会触发异常路径,最终保存出空文档。
修正:移除完成后 close 原始 doc 重新 open:
python
def remove(self, ...):
...执行移除并 save...
self.doc.close()
self.doc = fitz.open(self.file_path) # 重新加载原始文件
这样每次移除都从原始文档开始,多次移除互不影响,输出 file_clean.pdf → file_clean_2.pdf → file_clean_3.pdf 互不污染。
五、状态管理类
坑 11:初始化顺序错误导致 toolbar 切换引用未创建组件
症状 :主窗口构造时崩溃,错误是 AttributeError: 'MainWindow' object has no attribute 'cb_highlight'。
错误判断:以为 cb_highlight 这个 QCheckBox 没创建,去 _build_toolbar 里检查,发现确实创建了。
真正原因 :_build_toolbar 里有一行 self.cb_highlight.toggled.connect(self.preview.toggleHighlights),但 self.preview 是在 _build_center_panel 里创建的,而 _build_toolbar 在 _build_center_panel 之前调用。构造顺序错了,connect 时 self.preview 还不存在。
修正:所有信号连接放到 UI 全部构建完后:
python
def _build_ui(self):
...
self._build_toolbar(layout) # 只创建控件,不连接信号
...
self._build_center_panel()
...
self._build_right_panel()
...
# 所有面板构建完成后,连接信号
self.cb_highlight.toggled.connect(self.preview.toggleHighlights)
构造和连接分离,避免顺序依赖。
坑 12:批量模式打开文件清空列表导致退出批量状态
症状:批量检测后点左侧列表里的某个文件查看结果,结果列表被清空了,批量状态丢失。
错误判断:以为是点击信号连错了 slot,调试发现确实进了 _openFile,但 _openFile 里清空了 batch list。
真正原因:_openFile 写的是单文件打开逻辑,里面调 self._clearBatch()。在批量模式下点列表项触发的也是 _openFile,于是批量列表被清空。
修正:批量模式下点列表项走单独的 _openBatchFile,不清空列表:
python
def _openBatchFile(self, path: str):
"""批量模式下打开文件,不清空批量列表,不退出批量状态"""
...只加载文件,不操作 self._batch_mode 和 batch list...
_batch_mode 为 True 时点列表走 _openBatchFile,为 False 时走 _openFile,两个入口分开。
坑 13:batch detection_result 没同步到 session 导致移除按钮报错
症状 :批量检测后点某个文件,右侧显示候选列表正常,但点「移除」按钮报 AttributeError: session has no attribute detection_result。
错误判断:以为是 session 类没定义 detection_result 字段,检查 model.py 发现有。
真正原因:批量检测的 worker 把检测结果存在 task.detection_result 里,但点文件打开时只把 task 里的 path 加载到 session,没把 detection_result 同步过去。session.detection_result 还是 None,移除按钮的 slot 拿不到候选列表。
修正:_openBatchFile 里显式同步:
python
def _openBatchFile(self, path: str):
...
task = self._findTaskByPath(path)
if task and task.detection_result:
self.session.detection_result = task.detection_result
...
批量 task 的状态和 session 的状态必须双向同步,任何一个不同步都会让操作报错。
六、启动字体类
坑 14:高 DPI 缩放策略没设置导致启动 warning
症状 :启动时控制台刷 QHighDpiScaling: DPI rounding policy not set...,且 Windows 125% 缩放下界面发虚。
错误判断:以为是 PySide6 版本问题,升级到 6.5 没改善。
真正原因:QApplication 默认 rounding policy 是 Round,对 1.25x、1.5x 这种分数缩放取整会导致渲染目标尺寸和实际 DPR 不匹配,文字发虚。warning 是 PySide6 提醒你没显式设置。
修正:在 QApplication 创建前调用:
python
QApplication.setHighDpiScaleFactorRoundingPolicy(
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough)
PassThrough 不取整,让 Qt 按 DPR 真实比例渲染。这条必须放在 app = QApplication(sys.argv) 之前。
坑 15:.desktop 文件用 python3 命令导致麒麟 minimal 找不到
症状 :在银河麒麟 V10 minimal 安装的机器上,.desktop 文件双击没反应,命令行执行报 python3: command not found。
错误判断:以为是 .desktop 文件格式问题,反复改 Type/Version 字段没用。
真正原因 :Exec 字段写的是 python3 main.py,minimal 安装不带 python3 命令,只有 python3.10 这种带版本号的命令。
修正 :用 sys.executable:
python
python_exec = sys.executable
content = f"Exec={python_exec} {project_main} %f\n"
sys.executable 永远指向当前 Python 解释器绝对路径,开发模式下是 venv/bin/python,打包后是 PyInstaller 解压后的 ClearMark 可执行文件。无论哪种情况都能找到。
坑 16:.desktop 文件没加可执行权限导致 UKUI 不认
症状:.desktop 文件写进去了,Exec 也对,但任务栏还是不显示应用,开始菜单里也找不到。
错误判断:以为 UKUI 缓存了,clear cache 没用。又以为 .desktop 文件名不对,改名没用。
真正原因:UKUI 桌面要求 .desktop 文件有可执行权限,没权限的文件被当成普通文本文件,不进应用列表。
修正:写完 .desktop 后显式 chmod:
python
try:
os.chmod(desktop_path, 0o755)
except OSError:
pass
权限失败不影响程序运行,但任务栏图标可能不显示。这个细节在 GNOME 上不强制,但 UKUI 严格。
七、备份机制类
坑 17:备份目录创建失败阻塞文件打开
症状:用户打开一个文件,程序报错「无法创建备份目录」直接退出,文件根本没加载。
错误判断:以为是文件本身问题,调试了好久发现是 backup 目录创建失败(用户家目录只读)。
真正原因:_openFile 里先调 _backup(),备份函数尝试在源目录创建 backup/ 子目录。源目录如果是只读挂载(比如某些政企环境的受控目录),mkdir 失败抛异常,异常冒上来中断了 _openFile 整个流程。
修正:备份函数 try/except 包住,失败时 fallback 到 ~/.clearmark/backup/,再失败就静默跳过:
python
def _backup(self, src: str) -> Optional[str]:
try:
backup_dir = os.path.join(os.path.dirname(src), "backup")
os.makedirs(backup_dir, exist_ok=True)
...
except (OSError, PermissionError):
try:
# 回退到用户家目录
fallback_dir = os.path.expanduser("~/.clearmark/backup")
os.makedirs(fallback_dir, exist_ok=True)
...
except Exception:
return None # 备份失败不影响打开文件
return None
备份是辅助功能,不能因为备份失败让用户打不开文件。备份失败就提示一下「未自动备份,请手动保存原文件」,主流程继续。
八、检测逻辑类
坑 18:扫描件浅色斜铺水印 OCR 和颜色聚类都检测失败
症状:一份扫描 PDF,水印是浅灰色斜向平铺的文字,OCR 检测不到,颜色聚类也分不出水印簇。
错误判断:以为是 OCR 模型不够强,换更大的模型没用。又以为是聚类 k 值不对,调 k=8 也没分出来。
真正原因:浅灰色水印和正文颜色太接近(都是低饱和度的灰),且斜向旋转让常规水平文字 OCR 失效。颜色聚类把水印和正文归到同一簇,因为亮度差异小于聚类阈值。
修正 :写了一个像素级的 _light_text_geometry 检测器,专门处理浅色文字水印:
- 在像素层面统计局部亮度分布,找低于背景但又高于纯白的「半亮」像素
- 用形态学操作把这些像素连成连通域
- 对每个连通域计算最小外接矩形,如果长宽比和旋转角度符合「斜向平铺文字」特征(比如旋转 30-60 度、长宽比 > 3),认为是水印候选
- 对这些候选区域做旋转矫正后再 OCR,验证是否为可识别文字
这套流程绕过了「文字必须水平」的假设,对斜铺水印有效。
坑 19:中文字符宽度估算固定系数导致红框显示不全
症状:检测到水印后画红框,中文水印的红框只框住了一半,右边部分露出框外。
错误判断:以为是字体度量有问题,换了 QFontMetrics 还是偏。
真正原因 :我用了固定系数估算中文宽度:width = len(text) * font_size * 1.0。但中文字符实际渲染宽度不是 font_size 的 1.0 倍,全角标点和半角字母宽度也不一样。
修正 :用 unicodedata.east_asian_width 判断每个字符的宽度类别:
python
import unicodedata
def estimate_text_width(text: str, font_size: float) -> float:
width = 0
for ch in text:
eaw = unicodedata.east_asian_width(ch)
if eaw in ('W', 'F'): # 全角
width += font_size
elif eaw == 'A': # 模糊
width += font_size
else: # 半角
width += font_size * 0.5
return width
east_asian_width 返回 'W'(宽)、'F'(全角)、'A'(模糊)、'N'(窄)、'Na'(窄字符)、'H'(半角)。CJK 统一汉字是 W,英文是 Na/N。按类别乘不同系数,估算准确度大幅提升。
坑 20:智能检测没整合规则库关键词导致漏检品牌签名水印
症状:一份 PDF 的水印是「扫描全能王」,智能检测没识别出来,但规则库里明明有「扫描全能王」这个关键词。
错误判断:以为是规则加载失败,调试发现规则加载正常。又以为是关键词匹配逻辑有 bug,单测匹配逻辑也对。
真正原因:智能检测走的是「重复水印检测」算法(找跨页重复的文字块),但这份 PDF 只有第一页有签名水印,不重复,被智能检测判定为「非水印」。规则库检测要用户手动切到「规则库检测」模式才会跑,智能检测模式不调规则库。
修正 :智能检测里加一个 _detect_signature_watermarks 阶段,遍历规则库里的关键词,在 PDF 文字块里搜:
python
def _detect_signature_watermarks(self, page):
"""智能检测整合规则库关键词,识别品牌签名水印"""
text_blocks = page.get_text('blocks')
candidates = []
for rule in self.rules:
for keyword in rule.keywords:
for block in text_blocks:
if keyword in block[4]: # block[4] 是文字内容
candidates.append(...)
return candidates
智能检测现在分两个阶段:先跑重复水印检测找跨页水印,再跑签名水印检测找品牌关键词。两个阶段结果合并,漏检率大幅下降。
九、本篇小结
二十个坑,覆盖了 UI 渲染、线程并发、坐标系统、PDF 操作、状态管理、启动字体、备份机制、检测逻辑八个方面。每个坑的修正都不是「加上某行代码」那么简单,背后都涉及对 Qt、PyMuPDF、PDF 格式、操作系统机制的深入理解。
回头看这些坑,有几个共同教训:
- PySide6 不是 C++ Qt 的完美绑定:drawPixmap 二参重载不可用、QPixmap 真值判断不可靠、QThread 销毁规则更严,这些都要在 PySide6 特定语境下重新学习。
- PDF 是格式严格的对象系统:update_stream 只改字节不改字典,XObject 字典和字节流必须匹配,否则解码器罢工。
- 状态同步是批量模式的命脉:task 状态和 session 状态必须双向同步,任何一个不同步都会让后续操作报错。
- 跨平台细节比想象的多:字体名、文件权限、命令名、路径分隔符,每个细节都可能在另一个平台上给你一记闷棍。
- 检测算法不能只看一种特征:浅色斜铺水印在「重复检测」和「颜色聚类」下都失效,必须有多阶段兜底。
- 辅助功能不能阻塞主流程:备份失败、图标加载失败、.desktop 写入失败,都要 try/except 静默处理,不能让用户打不开文件。
这些坑我都钉死在了项目代码里,每条修正都加了详细注释,避免后来人重蹈覆辙。
十、系列结语
十二篇到这里就结束了。从第一篇「为什么做本地去水印工具」开始,我们走过了:
- 架构设计(第 2 篇):分层 + 处理器路由
- 内容流解析(第 3 篇):手写 content stream parser
- 矢量检测与移除(第 4 篇):多策略 + 区间擦除
- 扫描件(第 5 篇):像素级 OCR + 掩膜修复
- 图片(第 6 篇):颜色聚类 + Alpha 反演
- UI 上(第 7 篇):三栏布局 + QPainter 坐标契约
- UI 下(第 8 篇):对比模式 + 多分辨率图标
- 批量(第 9 篇):并发调度 + 任务状态机
- 规则库(第 10 篇):JSON DSL + 关键词加权
- 跨平台(第 11 篇):字体 + 高 DPI + .desktop + 打包
- 避坑实录(本篇):二十个真实坑的修正过程
每一篇都是真实开发过程的提炼,不是 AI 生成的概念堆砌。如果你正在做类似的工具------PDF 处理、桌面 GUI、图像修复、跨平台打包------这套源码和文档应该能给你不少启发。
📥 完整源码与可执行程序已上传 CSDN 资源,搜索「清印 ClearMark 智能文档去水印工作台」即可下载。