去年年底整理纸质合同的时候,单位的复合机只能一张张扫描再手动合并,来回跑了好多趟。后来我实在受不了,基于tkinter写了个桌面扫描工具,直接调用扫描仪硬件,把纸质文档扫成图片,再自动合成PDF。这个工具现在成了办公室的常驻小软件:打开软件、把文件放进ADF、点两下按钮,几十页合同自动变成一份PDF,预览、旋转、裁白边这些日常需求也随手做了进去。技术栈不复杂:界面用Python自带的tkinter,扫描协议用Windows自带的WIA接口,图像处理用Pillow,PDF合成用img2pdf。适合有同样扫描归档需求的办公族,也适合刚学完Python基础、想调用电脑硬件做实用小工具的开发者参考。
1. 项目整体设计与技术选型
1.1 需求拆解:扫描、预览、导出三件事
做这个项目前,我先把需求拆成了三件事:第一,能和扫描仪硬件通信,把纸上的内容变成图像数据;第二,能提供一个看得见、点得动的图形界面,让非技术同事也能操作;第三,能把扫出来的多张图片合并成一份PDF存档。三个子问题分别对应了WIA驱动层、tkinter界面层、PDF合成层,任何一个缺失都会让工具变成半成品。
实际用起来之后我才意识到,还有几个隐藏需求同样重要:批量扫描时要能实时看到进度,扫错的页面要能删掉重扫,图像方向不对要能旋转,PDF生成前要能调整页面顺序。这些功能看着都不起眼,但它们才是决定“同事愿不愿意用”的关键。如果扫完一摞纸只能导出不能改,那还不如用厂商自带的扫描软件。
所以在设计阶段我就确定了功能清单:设备选择、参数设置、单页预览、批量扫描、页面管理、旋转纠正、白边裁剪、PDF导出。后面的所有代码都围绕这份清单展开,不多也不少。
1.2 为什么用tkinter:工具型应用选型思路
界面框架的选择其实不用纠结太久。当时我也考虑过PyQt和PySide,界面确实现代,控件也丰富,但办公电脑不能随便装一堆依赖,而且目标用户对颜值的需求很低——打开软件、放纸、点扫描、拿PDF走人,没人会站在扫描工具前面欣赏UI。tkinter是Python自带的,环境里本来就存在,省去了一大堆部署问题。
tkinter还有一个容易被忽略的优点:它在Windows上会自动套用系统的ttk主题,配合ttk控件做出来的界面并不难看,只是朴素而已。对工具型应用来说,稳定、零额外依赖、代码量小才是真正的优势。项目做完以后我打包成exe发给同事,双击就跑,不用装任何运行时,这一点比界面好看重要得多。
1.3 扫描仪通信方案:WIA与TWAIN怎么选
Windows下扫描仪通信的传统方案有TWAIN和WIA两种。TWAIN是行业老协议,专业扫描软件里用得多,但Python社区能用的TWAIN库要么常年不更新,要么只支持32位解释器,在新版Python上安装非常折腾。WIA是Windows系统自带的扫描接口,通过pywin32的win32com就能调用,不需要单独装任何SDK,对常见家用和办公扫描仪的支持也够用。这个项目我选了WIA。
还有一条备选路径是用NAPS2的命令行工具,它内部封装了TWAIN、WIA和ESCL,稳定性很好。后来我在排障阶段也拿它当过参照物,用来判断问题出在代码还是出在驱动。我的建议是:主方案用WIA,遇到个别兼容性极差的杂牌扫描仪,再用NAPS2兜底,两条腿走路最稳。
1.4 依赖清单与运行环境
requirements.txt里只需要三个库:
pywin32 Pillow img2pdf运行环境以Windows为主,因为WIA是Windows组件。tkinter和Pillow虽然跨平台,但Linux下这套扫描代码连不上设备——Linux要走SANE协议,那是另一个项目。我开发时用的是Windows 10 64位 + Python 3.10,实测Python 3.8到3.12都能正常跑。
这里提醒一句:pywin32装完后,部分机器需要在命令行执行一次python Scripts/pywin32_postinstall.py -install来注册COM服务。如果代码里Dispatch WIA时提示“没有注册类”,先检查这一步。
2. 界面布局与交互设计
2.1 主窗口规划:左侧参数、右侧预览
主窗口我用了左右分栏布局。左侧是参数区,固定宽度约280px,从上到下依次放设备选择、扫描参数、操作按钮;右侧是预览区,上半部分显示当前扫描页的大图,下半部分放页面列表。整个布局用grid实现,窗口缩放时只有右侧预览区跟着变化,左侧参数区保持稳定。
这样安排的理由很实际:参数区要保证所有设置在任意窗口尺寸下都能完整可见,不能被挤掉;预览区是给眼睛看的,空间越大越好。我第一次做的时候把参数区放在顶部,窗口一拉长,按钮和列表挤成一团,后来改成左右分栏才舒服。如果你打算仿照这个项目,建议直接按左右结构来。
2.2 参数区设计细节
参数区的控件设计有几个细节值得展开。设备选择框用ttk.Combobox并设置state="readonly",禁止用户手动输入设备名,避免因为打错字导致连接失败。分辨率下拉只给150、200、300、600四档,不给任意输入框——WIA对任意dpi值的兼容性参差不齐,固定档位最省心。进纸方式用“平板/ADF”两个单选按钮,平板适合扫身份证这类单页物品,ADF就是自动进纸器,一次放一摞纸自动逐张扫描。
色彩模式用“彩色/灰度/黑白”三选一,这是决定文件体积的关键参数。纯文字合同选黑白,体积小、文字锐利;需要保留红章颜色就选彩色;带插图的文档选灰度。这些参数最终会直接映射到WIA属性上,映射关系在代码部分细说。参数区底部放了扫描、保存PDF、删除选中、清空列表四个按钮,扫描和保存按钮在任务进行中都要禁用,防止重复点击。
2.3 预览与页面列表联动:tkinter图像显示避坑
右侧下方的页面列表我用了tk.Listbox,每行显示“页码 - 文件名 - 文件大小”。选中某一行时,右侧大图区域加载该页的缩放预览图。这里有个tkinter经典问题必须说:ImageTk.PhotoImage对象如果只存在函数局部变量里,函数返回后对象会被垃圾回收,画布上立刻变成空白。解决办法是把它挂到实例变量上,比如self._current_photo = ImageTk.PhotoImage(...),强制持有引用。
另一个坑是内存。扫描几十页时,千万不能把所有大图都转成PhotoImage,否则界面卡死是小事,内存爆炸才是大事。正确做法是:列表里只保存图片文件路径和缩略图,用户选中某一页时再按需加载原图并生成预览。这样即使扫了一百页,窗口操作依然流畅。
2.4 线程处理与界面防卡死
扫描动作绝不能直接放在tkinter主线程里。WIA的Transfer方法会阻塞调用,用ADF扫一摞纸可能持续一两分钟,如果放主线程,窗口会立刻进入“未响应”状态,用户第一反应就是程序崩了。我的做法是点击“开始扫描”后用threading.Thread起一个后台线程,扫描线程每完成一页,就通过root.after(0, callback)把结果交回主线程更新列表和进度,这样界面能实时显示“已扫第3页”。
这里有一个非常容易踩的坑:WIA COM对象在哪个线程创建,就尽量在哪个线程里使用,跨线程调用偶尔会取不到属性,甚至直接抛异常。所以我的后台线程里包含完整的扫描流程——创建WIA对象、连接设备、循环逐页扫描、保存图片——而不是把主线程已经创建好的设备对象丢给子线程用。
3. 核心功能实现与代码解析
3.1 枚举扫描仪设备
WIA的设备枚举通过DeviceManager完成。调用win32com.client.Dispatch("WIA.DeviceManager")拿到管理器,然后遍历DeviceInfos集合。这个集合的下标从1开始,不是0,别写成range(0, Count),这是COM集合一个最常见的坑。
import win32com.client def list_scanner_devices(): wia = win32com.client.Dispatch("WIA.DeviceManager") result = [] for i in range(1, wia.DeviceInfos.Count + 1): info = wia.DeviceInfos(i) name = info.Properties("Name").Value result.append((name, info)) print(f"[{i}] {name}") return result设备信息对象info要保存下来,后面连接设备要用。如果DeviceInfos.Count返回0,说明系统层面就没发现扫描仪,不是你代码的问题,先按第5章的排障思路从驱动和服务查起。顺便说一句,设备名称一般和系统“传真和扫描”里显示的名字一致,看到它就说明WIA路径是通的。
3.2 执行扫描并获取图像
扫描单页的核心代码不长,但参数映射是重点。WIA的“Current Intent”属性接收一个枚举值:彩色是1,灰度是2,黑白是4,这个值在不同设备上基本一致。分辨率通过“Horizontal Resolution”和“Vertical Resolution”两个属性分别设置,一般设成相同值。Transfer方法的参数是目标图像格式的GUID,JPEG格式的GUID是固定的。
import time WIA_FORMAT_JPEG = "{B96B3CAE-0728-11D3-9D7B-0000F81EF32E}" INTENT_MAP = {"彩色": 1, "灰度": 2, "黑白": 4} def scan_page(device_info, dpi=300, intent="黑白", use_adf=True): dev = device_info.Connect() if use_adf: try: # 不同厂商对 Document Handling Select 的定义不同, # 我实测的这台一体机上 1=平板,2=ADF dev.Properties("Document Handling Select").Value = 2 except Exception: print("当前设备不支持代码切换ADF,走默认进纸方式") item = dev.Items(1) item.Properties("Horizontal Resolution").Value = dpi item.Properties("Vertical Resolution").Value = dpi item.Properties("Current Intent").Value = INTENT_MAP[intent] try: # 设为0表示不限制等待时间,适合大批量ADF item.Properties("Extend Time").Value = 0 except Exception: pass img = item.Transfer(WIA_FORMAT_JPEG) path = f"scan_{int(time.time() * 1000)}.jpg" img.SaveFile(path) return path这段代码里有一个调试技巧:如果你不确定设备支持哪些属性、哪些取值,可以写一个遍历函数把设备的属性名和值全部打印出来,看一眼就明白该怎么设置。实际开发中,Document Handling Select这个属性能不能设置、设置成什么值,不同厂商真是五花八门,打印属性是最快的确认方式。
线程协作的骨架大概是这样的:主线程点击按钮后启动_scan_worker线程,worker里循环调用scan_page,每成功扫出一页就self.root.after(0, self._on_page_done, path)回到主线程更新页面列表。整个扫描过程中禁用“开始扫描”和“保存PDF”按钮,等全部结束后再恢复。
3.3 图像预处理与显示
扫出来的图片往往带着一圈白边,方向也可能不对。白边裁剪我用的是ImageChops.difference配合getbbox:生成一张纯白背景图,和原图做差分,找到和白色差异最大的区域边界,再裁剪。这个方法对白底文档很有效,公式简单,实测下来比固定像素裁剪可靠得多。
from PIL import Image, ImageTk, ImageChops def trim_white_border(img, threshold=20): bg = Image.new("RGB", img.size, (255, 255, 255)) diff = ImageChops.difference(img.convert("RGB"), bg) bbox = diff.point(lambda x: 255 if x > threshold else 0).getbbox() if bbox: return img.crop(bbox) return img def rotate_if_landscape(img): if img.width > img.height: return img.transpose(Image.Transpose.ROTATE_90) return img def preview_photo(img, max_width=800): ratio = max_width / img.width new_h = int(img.height * ratio) thumb = img.resize((max_width, new_h), Image.LANCZOS) return ImageTk.PhotoImage(thumb)旋转逻辑比较“粗暴”:只要图片宽度大于高度,就顺时针转90度。这个假设基于大多数人扫描文档时是竖放纸,偶尔横放才会出现横向图。如果你们的扫描习惯不同,最好在界面上加一个手动旋转按钮,让用户自己处理,比代码猜方向更靠谱。
3.4 多页扫描件合并为PDF的两种方式
PDF合成我优先推荐img2pdf,代码只有三行:
import img2pdf def save_pdf(image_paths, out_path): with open(out_path, "wb") as f: f.write(img2pdf.convert(image_paths))为什么不直接用Pillow的多页保存?对比一下:
from PIL import Image # 不推荐用于彩色或灰度扫描件 images = [Image.open(p) for p in image_paths] images[0].save(out_path, "PDF", save_all=True, append_images=images[1:])img2pdf会把JPEG字节原样嵌入PDF,不重新编码,速度快、文件小。Pillow保存PDF时会重新编码成无损Deflate流,黑白二值图影响不大,但彩色图一张几MB,一二十页下来差距可能到几十MB。实测同样的十页彩色合同,img2pdf出来约15MB,Pillow直接存超过40MB,差距肉眼可见。所以这个项目里PDF导出只走img2pdf。
4. 实操记录:用一体机跑通全流程
4.1 实测环境与设备准备
我用的测试设备是办公室的一台HP LaserJet Pro M1536dnf MFP,USB连接,Windows 10 Pro 64位,Python 3.10,Pillow 10.2,pywin32 306。这台机器带ADF,最大支持A4,十几页的合同一次就能扫完。驱动装的是官网完整版,不是系统自动搜索的精简版。
这里有个经验供参考:如果你在“Windows 传真和扫描”这个系统自带程序里都看不到扫描仪,那问题一定出在驱动或系统服务层面,而不是你的Python代码。反过来,只要系统程序能扫,WIA枚举就一定能看到设备,代码里的设备列表就不会是空的。所以排障时,你的参照物应该是系统自带程序,而不是自己代码里的报错。
4.2 不同参数组合的扫描效果对比
为了给参数选择提供依据,我拿一份A4合同分别用不同模式扫了几遍,记录耗时和单页JPEG体积:
| 色彩模式 | 分辨率 | 单页耗时(约) | 单页JPEG体积(约) | 适用场景 |
|---|---|---|---|---|
| 黑白 | 150 dpi | 1秒 | 80~150 KB | 纯文字草稿 |
| 黑白 | 300 dpi | 2秒 | 300~600 KB | 正式存档 |
| 灰度 | 200 dpi | 1.5秒 | 300~700 KB | 带插图的文档 |
| 彩色 | 200 dpi | 2秒 | 800 KB~1.5 MB | 需保留印章颜色 |
| 彩色 | 300 dpi | 3~4秒 | 2~4 MB | 高清扫描或照片 |
黑白模式下300dpi扫文字合同,锐度已经足够,放大到200%依然能看清小五号字;150dpi笔画会粘连,正式存档我不推荐。彩色模式下扫带红章的文件,200dpi是底线,150dpi印章边缘会出现明显锯齿,扫描件一旦放大就露馅。如果你存的是重要合同,彩色默认就选200dpi,别贪体积去选150dpi。速度数据仅供参考,不同设备差异很大,同一台设备进纸速度也会受纸张厚度影响。
4.3 整批扫描到PDF的完整流程
实测中,我拿一份10页的合同走了完整流程。步骤是:
- 打开软件,设备下拉框选择“HP LaserJet Pro M1536dnf MFP Scan”,等待设备枚举完成后自动填入。
- 进纸方式选ADF,分辨率300,色彩模式选黑白。
- 把合同纸叠整齐,正面朝上放进ADF纸槽,两侧卡位推到贴纸位置。
- 点击“开始扫描”,状态栏显示“正在扫描第1页 / 共10页”,每扫完一页,页面列表就多一行记录。
- 扫描结束后,逐页检查预览图;发现某页方向不对,选中它点旋转按钮。
- 确认无误后点击“保存PDF”,选择输出路径,软件在后台合并页面并弹出完成提示。
- 用系统PDF阅读器打开文件,检查页序、清晰度和文件大小。
整套流程下来,从放纸到拿到PDF文件,大约20秒。文件大小约3.2MB,页序正确,文字清晰,完全满足合同归档要求。整个过程中最耗时的反而不是扫描本身,而是人工检查页面方向和页序。后面我把这套检查做成“扫描后自动执行旋转和白边裁剪”,才又把操作时间压缩了一半。
5. 常见问题与排查心得
5.1 设备列表为空怎么办
设备枚举不出扫描仪,90%的情况不是代码问题。第一步,打开“Windows 传真和扫描”,看系统能不能发现设备。如果这里也看不到,第二步检查Windows服务:services.msc里找“Windows Image Acquisition (WIA)”服务,确保它已经启动并设为自动。第三步,打开设备管理器,找到扫描仪设备,右键卸载后重新扫描硬件,然后装官网完整版驱动。用USB连接的话,尽量插主机后置USB口,前置口和延长线供电不稳会导致扫描仪间歇性消失。
有一个坑我必须单独提:很多设备标注“即插即用”,但WIA驱动其实需要额外组件。比如HP那台一体机,只装打印驱动不装扫描驱动的情形很常见,结果就是打印正常、扫描枚举不到设备。装驱动时尽量选“完整版”而不是“仅打印”。装完重启一次系统,再跑list_scanner_devices(),基本就出来了。
5.2 扫描卡死、超时与界面假死
扫描扫到一半不动,是最常见也最让人抓狂的问题。经验告诉我,大批量ADF扫描时,如果扫描仪一段时间没有检测到进纸,WIA内部的超时机制就会触发,Transfer直接抛异常。代码里我加了Extend Time属性并设为0,表示无限等待,这在很多设备上能缓解这类超时问题。但个别设备根本识别不了这个属性,try/except兜住就行。
界面假死的问题分两种。一种是主线程被Transfer阻塞,解决办法前面说过,起后台线程。另一种是内存问题——600dpi彩色扫描一张A4,图像解码后内存占用轻松上几百MB,ADF连续扫十几页内存就会飙升。我的做法是每页扫描完成后立刻把图像文件路径和缩略图加入列表,然后释放原图对象,预览时再按需加载。如果你扫到一半发现窗口响应越来越慢,优先检查是不是没有及时释放PIL图像对象。
5.3 页面顺序、方向与歪斜问题
ADF扫出来的页序是正是反,这个必须实测确认,不能想当然。找三张纸分别标上1、2、3放进ADF扫一遍看顺序。如果反了,在保存PDF前对页面列表做一次reverse()。有的驱动会帮忙把顺序调整成正序,有的不会,实测过才知道。我做的第一个版本没做这个判断,导出PDF后页序全反,被同事笑了一下午。
方向问题目前靠自动横向转正加手动旋转按钮双保险。歪斜问题比较麻烦,自动裁边只能处理轻微歪斜,严重的歪斜需要OpenCV做霍夫变换检测倾斜角度再去旋转,这个工具暂时没做。如果你们公司有大量扫描需求,我建议这种场景直接上专业扫描软件或OCR厂商的SDK,自己用Pillow从零写纠偏投入产出比不高。
5.4 PDF文件太大怎么办:清晰度与体积平衡技巧
PDF体积失控几乎都是参数选错导致的。我见过同事用600dpi彩色扫合同,30页出来200多MB,根本没法邮件发送。正确思路是按内容选参数:纯文字合同,黑白300dpi足矣;需要保留印章颜色,彩色200dpi;带有照片或灰度图的文档,灰度200dpi。这三个组合基本覆盖了办公场景的绝大多数需求。
如果PDF还是大,可以用Pillow先对JPEG做质量压缩再交给img2pdf合并。比如img.save(tmp_path, "JPEG", quality=70),质量降到70左右,肉眼几乎分辨不出区别,体积能再小三分之一左右。再次强调,合并阶段必须走img2pdf,Pillow直接存PDF会让彩色图体积成倍增加,这是编码方式决定的,换参数解决不了。
5.5 打包发布与后续扩展
工具给同事用时,打包成单个exe是最省心的方式。PyInstaller命令很简单:
pyinstaller --onefile --windowed main.py这里有两个坑。第一个,窗口程序一定要加--windowed,否则每次启动都会弹一个黑乎乎的终端窗口,观感很业余。第二个,打包出来的exe首次运行时容易被杀毒软件拦一下,这是PyInstaller单文件程序的通病,添加信任即可。
这个工具后续可以扩展的方向其实不少。比如给PDF自动命名,按“合同_2025-01-25.pdf”这种格式存;再比如加OCR识别关键字段,归档时自动分类;双面扫描如果一体机不支持硬件双面,就扫完正面翻面再扫一次,合并时把第二面的列表reverse以后再接到第一面后面。代码结构上留好了接口,扩展起来不难。
这套工具我用了快半年,最明显的体会是:扫描——尤其是批量扫描——真正的瓶颈根本不在代码,而在设备和纸本身。驱动装不对,代码再对也白搭;纸放歪了,扫出来就是歪的。所以做这类工具时,别一头扎进代码里,先花半天把设备调通、把流程跑顺,后面开发会省心很多。如果你也在为扫描归档头疼,这个思路直接抄就行,改改参数和按钮就能变成你自己的工具。