
简介AA-Scan是一套面向Arduino与Android平台的开源极简全自动3D扫描仪项目源代码适合硬件爱好者、创客以及计算机视觉初学者参考学习也可用于物联网课程设计或创客比赛前期研究。压缩包仅16KB共5个文件2个Python脚本分别承担客户端与服务端逻辑1个Arduino程序用于控制转台转动另有许可证文件与说明文档便于查看项目背景与使用方式。项目通过手机或PC与Arduino配合可自动完成物体的多角度图像采集与扫描控制代码结构精简模块划分清晰便于快速理解整个扫描流程与双端通信机制。当前已有260人学习下载读者可将它作为一份可直接阅读的开源实现从中了解3D扫描仪的搭建思路借鉴Python通信代码与Arduino控制代码进行二次开发结合Thingiverse页面上的硬件组装细节即可动手复现并继续优化。 如果你家里有一台吃灰的平板扫描仪大概体会过这种崩溃原厂驱动软件界面过时扫出来不是歪就是带黑边批量扫一份几十页的合同时每一页都要手动裁剪、调亮度扫完的文件还搜不了关键字。市面上的手机扫描 App 免费版带水印会员一年几百块。我花了两个晚上用 Python 写了一条自动化扫描流水线起名 AA-Scan。AA 是 Auto-Align 的缩写作用很直接把“扫描”变成一条可编程流水线放纸、按开关剩下交给代码。它不挑扫描仪品牌也不太挑操作系统核心只做四件事自动裁边、透视校正、背景清理以及 OCR 之后导出可搜索 PDF。整个记下来也没那么玄乎核心就几段代码加一堆调参经验。这篇把我踩过的坑和最终的参数直接写出来想自建这套流程的照抄即可。1. 不买新设备AA-Scan 的定位与整体设计思路1.1 为什么叫 AA-Scan起这个名字的时候脑子里想的就是“自动对齐裁剪”。传统扫描仪扫出来的原始图像几乎不可能恰好是整份文档总会带上盖子阴影、黑边、纸张歪斜。AA-Scan 的第一目标是把这些脏活全部自动化——你只需要把纸放上去它负责找到纸的边界、拉正视角、裁掉多余部分。AA 还有第二层意思adaptive acknowledgment。扫描过程中每处理一页都会对比前后两页的内容和尺寸发现异常就停下来问你要不要手动修正保证批处理不会一路错下去。1.2 整条流程怎么分工我的处理流水线不长但每一段都是独立的模块方便单独调试。采集通过 SANE/WIA 驱动扫描仪或者直接读取手机拍好的照片预处理边缘检测、轮廓查找、透视变换得到“只有纸面”的规整图像增强去除灰底、阴影、偏色同时保留彩色原图OCR用 Tesseract 识别中英文把结果嵌入 PDF输出多页合成、按需命名、碰到空白页自动丢弃其实这五步对应了扫描仪从硬件到软件的全部链条。我特意把每一步做成函数命令行传参可以指定只跑到哪一步方便排查到底是采集问题、算法问题还是识别问题。1.3 技术栈和版本选型我用的 Python 3.10OpenCV 4.7Tesseract 5.3pyinsane2 用于 Linux 扫描Windows 那边用 WIA 的 COM 接口。PDF 合并直接用 img2pdf不经过 PIL避免图像被重新压缩导致体积膨胀。版本这块踩过一个小坑老版本 Tesseract 的 chi_sim 语言包和 5.x 的 LSTM 引擎兼容性不好识别率差别挺大。所以如果你要复现中文 OCR 的效果优先装 Tesseract 5别用发行版源里的 3.x 老包。2. 采集层打通让普通扫描仪和手机镜头接入流水线2.1 Linux 下接管扫描仪SANE 与 pyinsane2在 Linux 下最顺的方式是 SANE它把各家扫描仪统一成一套接口。先用 scanimage -L 看设备能不能被识别scanimage -L正常会看到类似plustek:libusb:001:004这样的设备标识。接下来先用命令行扫一张测试图scanimage -d plustek:libusb:001:004 \ --resolution 300 --mode Color \ --formatpng test.png这里两个参数值得说明。分辨率我固定 300 dpi这个是 OCR 中文的性价比下限。200 dpi 不是不能用但小五号字识别率会掉得厉害。色彩模式用 Color 而不是 Gray因为灰度模式扫描出来的图本身已经丢了纸底色信息后面做背景均匀化校正的时候原始彩色图的容错率更高。Python 侧如果不想手动解析命令行用 pyinsane2import pyinsane2 pyinsane2.init() devices pyinsane2.get_devices() scanner devices[0] scanner.resolution [300] scanner.mode [Color] pyinsane2.maximize_scan_area(scanner) scan scanner.scan(multipleFalse) img scan[0].frames[0] img.save(page.png)注意 pyinsane2 的设备属性用的是列表赋值这是它 API 的别扭之处写惯了直接赋值很容易漏。另外扫描仪在 libusb 下经常遇到权限问题建议给设备加一条 udev 规则否则换了 USB 口就得重新跑一次 root。2.2 Windows 下的 WIA 快速路径Windows 下扫描没有 Linux 那么统一最省事的办法是用 WIA 2.0 COM 接口Python 里借助 pywin32 调用。import win32com.client wia win32com.client.Dispatch(WIA.DeviceManager) device wia.DeviceInfos[0].Connect() item device.Items[0] item.Properties[CurrentIntent].Value 3 # 彩色 item.Properties[HorizontalResolution].Value 300 item.Properties[VerticalResolution].Value 300 image item.Transfer({B96B3CAE-0728-11D3-9D7B-0000F81EF32E}) image.SaveFile(page.png)这段代码里最容易踩的坑是 GUID 别写错那是 WIA 的 PNG 格式标识。另外CurrentIntent的值1 表示灰度、2 表示黑白、3 表示彩色调错了扫出来就是黑白图。如果你在 Windows 下只求快速跑通还有一个笨办法用厂商驱动把扫描件存成 JPG 文件再交给后续的 OpenCV 处理。这个路径虽然不够优雅但绕开了所有驱动 API 的兼容问题。2.3 手机拍照作为备用输入源我加这条路径纯粹是懒得分页。平扫仪一次只能放一页几十页合同翻来翻去很累。用手机摄像头整页拍摄速度能快很多。手机拍照的输入源预处理比扫描仪麻烦因为透视变形更严重而且容易有反光。我的经验就三条纸下面垫一块深色硬卡纸这样边缘检测有对比度镜头尽量垂直对准纸面中央保证拍照环境光线均匀别开闪光灯。手机拍出来的图分辨率只要超过 3000px 长边后面的算法效果都不会太差。3. 自动裁边与透视校正最核心的图像处理实现与参数调优3.1 最大四边形检测拿到一张扫描图之后第一件事是找纸。我的思路是先用 Canny 边缘检测再找所有轮廓里的四边形按面积排序取最大那个。下面是完整的查找函数import cv2 import numpy as np def find_paper_contour(image): gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) blurred cv2.GaussianBlur(gray, (5, 5), 0) edged cv2.Canny(blurred, 50, 150) contours, _ cv2.findContours( edged, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE ) contours sorted(contours, keycv2.contourArea, reverseTrue) for cnt in contours: peri cv2.arcLength(cnt, True) approx cv2.approxPolyDP(cnt, 0.02 * peri, True) if len(approx) 4 and cv2.isContourConvex(approx): return approx return NoneCanny 的高低阈值 50 和 150 是我试出来的在大多数扫描场景下都能把纸面的四条边检测出来。如果发现纸边断断续续可以把第二个参数降到 120但别低于 100否则文档里的文字笔画会变成大量噪声边缘反而不利于轮廓筛选。approxPolyDP的 0.02 参数决定了多边形近似的误差它把几百个点的轮廓压缩成尽可能少的顶点。扫描图上纸的边界有一点点弯曲也能被压缩成四边形这是它能抗物理噪声的关键。3.2 顶点排序与透视变换检测出四个顶点之后直接投到透视变换会出错因为轮廓返回的角点顺序不保证是左上、右上、右下、左下。我用非常经典的像素坐标排序法def order_points(pts): rect np.zeros((4, 2), dtypefloat32) s pts.sum(axis1) rect[0] pts[np.argmin(s)] rect[2] pts[np.argmax(s)] diff np.diff(pts, axis1) rect[1] pts[np.argmin(diff)] rect[3] pts[np.argmax(diff)] return rect这个方法的原理不复杂左上角的 xy 最小右下角的 xy 最大右上角的 x-y 最小左下角的 x-y 最大。在扫描场景下足够稳定不需要更复杂的算法。然后根据四边形的宽和高构造目标坐标完成透视变换def transform_perspective(image, pts): rect order_points(pts.reshape(4, 2)) (tl, tr, br, bl) rect width max(int(np.linalg.norm(br - bl)), int(np.linalg.norm(tr - tl))) height max(int(np.linalg.norm(tr - br)), int(np.linalg.norm(tl - bl))) dst np.array([ [0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1] ], dtypefloat32) M cv2.getPerspectiveTransform(rect, dst) return cv2.warpPerspective(image, M, (width, height))这里取了四边形四条边里较长的值作为目标宽高避免倾斜角度导致输出画面被截断。实际效果是哪怕原图里纸只占了三分之一变换后也能得到一张完全平正、充满画面的文档图。3.3 背景清理和二值化扫描仪扫出来的图像常有一层不均匀的灰底特别是纸张厚度不够、背面字印透过来的时候。直接二值化会有大面积黑斑。我的做法是先估计背景亮度再做一个除法校正。用一个 sigma 很大的高斯滤波器把纸面的文字和图案全糊掉剩下的就是亮度的整体分布bg cv2.GaussianBlur(gray, (0, 0), sigmaX30) corrected cv2.divide(gray, bg, scale255)这一步的原理是扫描件可以近似看作“反射率 x 光照”光照不均匀是一个乘性噪声。用高斯核提取出的 bg 代表低频光照两者相除就把它剥离了。大 sigma 是关键太小会把文字边缘也算进背景导致校正过度。黑白文档在校正之后用自适应阈值bin_img cv2.adaptiveThreshold( corrected, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 10 )blockSize 31、C 10 是中文文档的稳定参数。C 值过小会让笔画浓郁区域整体变黑C 值过大则笔画变淡。这个只能微调无法一概而论。彩色文档不建议二值化校正后的彩色图直接用来 OCR 和存档更自然。4. 中文 OCR 与可搜索 PDF从乱码到可检索的合同4.1 Tesseract 安装与参数选择OCR 我选了 Tesseract理由只有一个它是唯一一个能免费跑中文识别、且准确率可以靠调参救回来的引擎。安装时记得同时装语言包sudo apt install tesseract-ocr tesseract-ocr-chi-simPython 侧只要一个 pytesseract 包。调用时我固定用 LSTM 引擎和单栏排版模式import pytesseract text pytesseract.image_to_string( bin_img, langchi_simeng, config--oem 1 --psm 6 )--oem 1的意思是强制 LSTM 引擎旧引擎对中文支持很差。--psm 6表示把整页当成一个统一文本块适合大部分合同和论文。如果页面是多栏的改成--psm 4效果更好它会让 Tesseract 按列分段识别。4.2 生成可搜索 PDFOCR 文字如果只是输出 txt那扫描文档还是没法按关键字检索。Tesseract 其实可以直接生成带隐藏文字层的 PDF一条命令搞定tesseract page.png page -l chi_simeng --psm 6 pdf这条命令会生成page.pdf里面每一页都是扫描图像覆盖在识别文本层上既能看原图又能选中复制文字。批处理时在循环里跑这条命令比在 Python 里调底层接口要省心得多。如果你要批量合并所有页我建议保留每页的 PDF最后用qpdf --empty --pages *.pdf -- out.pdf合并或者直接用 Python 的 pypdf 把单页 PDF 拼起来。别先用 img2pdf 合并图片再统一 OCR那样中间步骤一旦出错重新跑的成本翻倍。4.3 中文识别率翻车的三个原因我最初在几页扫描件上跑 OCR准确率惨不忍睹反复试下来问题就三类。第一图像被二值化得太狠。笔画一旦粘连Tesseract 会把一个字的左右结构拆成两个字。后来我把 OCR 的输入从二值图换成了背景校正后的灰度图识别率反而更高因为它保留了笔画的粗细信息。第二分辨率不够。200 dpi 扫出来的中文小字号识别基本随缘。300 dpi 是比较稳的起点。这也是我没有盲目调高分辨率的原因600 dpi 不会显著提升识别率却会让扫描和处理时间翻倍。第三文字没有先拉正。Tesseract 对倾斜非常敏感超过 4 度的倾斜就能让识别率掉 20% 以上。所以透视校正必须是 OCR 的前置步骤不能跳。5. 批量实测与兜底方案处理 70 页合同的真实数据5.1 批处理与空白页跳过真正落地的扫描任务不是处理一页而是几十上百页。我写了个目录批量处理函数主要逻辑是循环读图、跳过空白页、逐页处理、最后合并import glob import numpy as np import img2pdf def is_blank(gray, threshold6.0): return np.std(gray) threshold def process_directory(input_dir, output_pdf): pages [] for path in sorted(glob.glob(f{input_dir}/*.png)): img cv2.imread(path) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if is_blank(gray): continue result pipeline(img) pages.append(result) with open(output_pdf, wb) as f: f.write(img2pdf.convert(pages))空白页检测我用的是灰度标准差。扫描完背面空白、没有内容的纸像素值几乎一样标准差会很小。设成 6.0 是因为实测空白页通常在 2~4 之间带文字页在 15 以上。这个阈值对纯表格和图表页面也适用。顺手加了一个对数记录功能处理完每一页把耗时写进 CSV方便分析瓶颈。5.2 70 页合同实测数据我用一台普通家用平板扫描仪扫了 70 页 A4 合同整条流水线在 i5 笔记本上稳定跑完数据如下处理阶段单页耗时说明硬件扫描12~18 秒300 dpi 彩色受扫描仪机械速度限制预处理透视校正0.8~1.2 秒包含边缘检测和透视变换背景清理0.3~0.5 秒高斯估计除法校正OCR2.5~4.0 秒中文英文混排单栏单页总计16~24 秒不含人工换纸时间70 页全部完成大约 28 分钟其中扫描和换纸占了八成时间。OCR 反而是可接受的快因为 300 dpi 单页分辨率并不夸张。整批处理中自动裁剪成功 66 页还有 4 页因为纸张上有大量手写批注边缘检测把批注区域误当成纸面进入了手动修正流程。这个比例我觉得完全可以接受。5.3 边缘检测失败的兜底策略自动检测再稳也会有边界场景。我发现最典型的是两种一是白纸放在白色盖板上检测出来的四边形只框住了文字区域二是页面边缘有撕裂或深色霉斑轮廓被带歪。兜底方案我做了两层。第一层是几何校验在 find_paper_contour 返回结果之前检查四边形面积占整图面积的比例小于 15% 直接判定为异常不进入自动流程。第二层是手动框选roi cv2.selectROI(manual, image) cv2.destroyAllWindows()用户框出纸面范围后AA-Scan 会把框选区域直接作为裁切结果跳过检测逻辑。自动加手动的组合让整条流水线几乎不会卡死。最后分享一点个人体会。我在写这套流水线之前也纠结过要不要直接买一台自动馈纸扫描仪。现在用 AA-Scan 处理完几批文档后反而庆幸没有买——硬件只是把纸扫成一张图真正的智能感来自软件。扫描这件事算法能解决的问题比硬件多得多。后续我还打算给它的输出加上条形码识别和自动分类归档让扫描件落地即入库。这套代码从第一版跑通到现在已经稳定处理了几百页文档如果你也经常被批量扫描折磨非常建议照着上面的思路搭一套。本文还有配套的精品资源点击获取