ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

VC++基于TWAIN协议控制扫描仪:从状态机到图像采集实战

VC++基于TWAIN协议控制扫描仪:从状态机到图像采集实战 在Windows平台上做扫描仪控制TWAIN协议一直是绕不开的老牌标准。很多VC开发者一开始会纠结到底该用WIA还是TWAIN如果你的目标设备是专业扫描仪、高速文档扫描仪或者需要在UI上精细控制分辨率、色彩深度、区域扫描这类参数TWAIN依然是兼容性最好、功能最完整的方案。这篇博文我就从零起步带你把“VC中基于TWAIN协议控制扫描仪”的初级版本跑通从理解TWAIN协议的角色模型到编写标准状态机调用再到把扫描仪面板弹出来获取图像数据最后落成可直接复用的代码框架。无论你是刚接触图像采集的C新手还是想快速交付一个扫描功能模块的职场开发这篇文章都能给你一条明确的路径顺便帮你绕开我当年踩过的那些暗坑。1. 内容整体设计与思路拆解1.1 TWAIN协议到底是什么很多资料喜欢把TWAIN说成“图像采集标准接口”但这句解释对写代码帮助不大。我更愿意用三个人物的关系来理解它TWAIN协议里定义了三个角色Application应用程序就是你的VC程序它负责发起扫描请求、接收图像数据。Source Manager数据源管理器DSM相当于一个设备管理器负责枚举系统里已安装的扫描仪驱动程序并调度应用与设备之间的通信在Windows上通常由系统提供的twain_32.dll承担。Source数据源DS每个厂商扫描仪的驱动程序就是一个数据源例如富士扫描仪6130z、佳能、惠普等各自的TWAIN驱动。应用程序不直接和设备硬件通信而是通过DSM与DS交互。一个形象的类比是你想找一位工程师干活但你不需要知道工程师住在哪、用什么工具你只需要找到“项目经理”DSM由项目经理把合适的“工程师”扫描仪驱动派给你。这种架构最大的好处是应用层代码与具体设备解耦换一台不同型号的扫描仪只要它提供TWAIN驱动你的应用程序几乎不用改。1.2 为什么选择TWAIN而不是WIA现在的Windows系统都支持WIA和Windows Image Acquisition为什么还要学TWAIN这得从实际设备兼容性说起。WIA是微软主推的简化接口代码上手容易但很多中高端扫描仪在WIA模式下会“隐藏”一些高级参数比如光学分辨率档位、双流输出、纸张长度检测等。而TWAIN在专业影像采集领域沉淀了二十多年无论是身份证扫描、票据扫描还是高速文档扫描仪驱动程序对TWAIN的支持都最全面、最稳定。举一个我在项目中遇到的真实场景客户要求用富士扫描仪6130z扫描超长纸张WIA模式下驱动始终不暴露长纸模式设置切换到TWAIN之后通过设置ICAP_PIXELTYPE和ICAP_SUPPORTEDSIZES关联的自定义尺寸问题立刻解决。这就是选择TWAIN的核心原因——它是设备能力的最完整映射。1.3 初级版的目标界定既然叫“初级版”我们的目标就不是开发一个完整的图像采集工作流而是跑通最小可用闭环调用TWAIN协议弹出扫描仪厂商自带界面让用户手动设置扫描参数用户点击扫描界面上的“扫描”按钮后获取图像数据将图像数据转换为DIB位图并在你的VC窗口上显示或被其他模块使用。至于绕开厂商UI、完全用代码控制扫描参数例如直接通过MSG_SET设置分辨率、批量连续扫描、内存传输模式等那是进阶版的内容本文最后我会简单交代扩展方向但不过度展开。2. TWAIN核心数据结构与状态机解析2.1 必须记住的几个TW结构体初学TWAIN最容易懵的地方是结构体太多。其实你只需要抓住四个关键结构初级版就够用了。第一个是TW_IDENTITY每个角色App、DSM、DS都有一份身份信息。应用程序需要填充这个结构体传给DSMDSM返回数据源的TW_IDENTITY。关键字段包括Id消息中用到的标识符、Version、ProtocolMajor、ProtocolMinor、Manufacturer、ProductName等。第二个是TW_USERINTERFACE它的作用很简单传递一个布尔值ShowUI。初级版我们需要把它设为TRUE让驱动弹出自己的扫描界面。如果设为FALSE就是“无UI模式”得靠代码逐项设置参数这是进阶玩法。第三个是TW_IMAGEINFO在图像传输阶段协议用这个结构体告诉你图像的宽、高、分辨率、像素类型、位深等信息。拿到它之后才能正确解析图像数据。第四个是TW_MEMORY虽然初级版用NATIVE传输模式但某些情况下系统会把图像数据通过TW_MEMORY句柄传递你需要知道怎么去GlobalLock。此外还有TW_STATUS它包含ConditionCode当调用出错时用MSG_GETSTATUS获取详细错误码。这个在调试阶段特别好使后面排查问题那一节我会专门说。2.2 状态机从State 1到State 7TWAIN这套协议的核心其实是一个状态机。理解不了状态机后面的代码全是乱的。简单来说协议把应用与数据源之间的交互过程拆成了7个状态State 1应用加载DSM之前双方无连接。State 2应用已经打开DSM但还没有打开任何数据源。State 3DSM已经打开了一个数据源可以查询/设置设备参数。State 4DS已经启用UI但扫描还未开始。State 5扫描过程中的数据传输阶段。State 6一页图像的数据已经准备好应用可以取走。State 7传输结束返回到State 4或State 3。初级版代码就是在这几个状态之间来回跳。你写的DSM_Entry函数调用本质上是告诉系统“我要从当前状态迁移到目标状态”。比如MSG_OPENDSM让状态从1到2MSG_OPENDS让状态从2到3MSG_ENABLEDS让状态从3到4。2.3 模块划分与文件组织我没有把扫描代码直接写在对话框类里而是拆成了独立的类CTwainScanner。这样做的好处是以后想移植到MFC或Win32项目里或者从对话框程序升级到文档视图架构代码都能直接复用。一个建议的文件组织如下TwainScanner.h/TwainScanner.cpp封装TWAIN调用的核心类。TwainDefs.h包含twain.h头文件以及必要的宏定义。resource.h对话框资源用于接收TWAIN消息的窗口句柄。在实际项目里我会把CTwainScanner设计成一个全局单例因为扫描操作通常是耗时操作而且TWAIN消息回调需要一个稳定的窗口句柄单例能保证整个应用生命周期内扫描对象只有一个避免重复加载twain_32.dll带来的资源浪费。3. 实操过程与核心环节实现3.1 开发环境配置如果你是初学者我推荐在Visual Studio环境下创建一个MFC对话框应用程序。TWAIN协议并不依赖MFC但MFC的消息循环和对话框机制能让我们非常方便地接收TWAIN事件消息。需要注意的一点是TWAIN SDK头文件twain.h需要从官网或SDK包中获取。很多老项目用的是TWAIN 1.9规范现代驱动大多数也仍然兼容。我建议你直接下载最新的TWAIN规范头文件版本至少包含TWAIN 2.x的TW_IDENTITY字段扩展。配置过程很简单将twain.h放到项目目录并在TwainDefs.h里#include。链接twain_32.lib在项目属性-链接器-输入-附加依赖项中添加。预处理定义中添加TWLIB宏确保函数导入导出正确。有人说不需要链接twain_32.lib直接用LoadLibrary动态加载更灵活。确实动态加载twain_32.dll有它的优势比如在程序启动时不必关心系统是否安装了TWAIN组件。但初级版为了减少复杂度直接用静态链接就够了代码更清爽。3.2 第一步加载DSM并建立会话开头当然是加载DSM获得入口函数指针。关键点是调用DSM_Entry时第一个参数是指向App身份结构的指针第二个参数是DG_CONTROL第三个是DAT_PARENT第四个是MSG_OPENDSM第五个是窗口句柄。核心代码如下bool CTwainScanner::InitTwain(HWND hwndApp) { m_hwndApp hwndApp; // 填充App身份信息 memset(m_appId, 0, sizeof(TW_IDENTITY)); m_appId.Id 0; m_appId.Version.MajorNum 1; m_appId.Version.MinorNum 0; m_appId.Version.Language TWLG_ENGLISH; m_appId.Version.Country TWCY_USA; m_appId.ProtocolMajor TWON_PROTOCOLMAJOR; m_appId.ProtocolMinor TWON_PROTOCOLMINOR; m_appId.SupportedGroups DG_IMAGE | DG_CONTROL; strcpy_s(m_appId.Manufacturer, MyCompany); strcpy_s(m_appId.ProductFamily, MyFamily); strcpy_s(m_appId.ProductName, TwainScannerDemo); // 调用MSG_OPENDSM TW_UINT16 rc DSM_Entry(m_appId, DG_CONTROL, DAT_PARENT, MSG_OPENDSM, (TW_MEMREF)m_hwndApp); if (rc ! TWRC_SUCCESS) { return false; } return true; }这里有个容易犯的错误DAT_PARENT消息的第五个参数不是窗口句柄本身而是窗口句柄的地址。也就是说你要传递(TW_MEMREF)hwndApp而不是(TW_MEMREF)hwndApp。我第一次写反导致打开DSM一直失败后来看官方示例才明白原因。3.3 第二步选择数据源并打开MSG_OPENDSM成功之后程序进入State 2。接下来要选择扫描仪。有两种方式第一种是弹出一个系统级的选择对话框让用户从已安装的驱动列表里挑对应消息是MSG_SELECTDS第二种是直接按产品名查找数据源对应MSG_SELECTDS加上MSG_OPENDS的组合但需要预先枚举。我建议初级版用系统UI选择这样代码量最少bool CTwainScanner::SelectSource() { TW_UINT16 rc DSM_Entry(m_appId, DG_CONTROL, DAT_IDENTITY, MSG_SELECTDS, (TW_MEMREF)m_srcId); return (rc TWRC_SUCCESS); }MSG_SELECTDS会弹出一个对话框对话框里列出系统里所有TWAIN数据源。用户选完之后m_srcId会被填充为该数据源的身份信息。注意这里必须传入一个TW_IDENTITY的指针不能再传DAT_PARENT了。接下来用MSG_OPENDS打开该数据源bool CTwainScanner::OpenSource() { TW_UINT16 rc DSM_Entry(m_appId, DG_CONTROL, DAT_IDENTITY, MSG_OPENDS, (TW_MEMREF)m_srcId); if (rc ! TWRC_SUCCESS) { return false; } SetState(3); return true; }打开数据源之后系统进入State 3。此时你其实已经可以查询设备能力了但初级版我们直接跳过参数设置把UI交给用户。3.4 第三步启用数据源UI并进入消息循环这一步是整个流程里最容易让新手卡壳的地方。MSG_ENABLEDS启用了数据源的用户界面但这之后程序并不是简单地阻塞等待扫描完成而是需要你持续地把Windows消息转发给TWAIN数据源。我第一次写的时候直接调用了MSG_ENABLEDS然后傻傻地等MSG_XFERREADY结果界面卡死扫描仪也没反应。原因就是没有做消息循环的透传。正确流程如下bool CTwainScanner::EnableSourceUI() { TW_USERINTERFACE ui; memset(ui, 0, sizeof(TW_USERINTERFACE)); ui.ShowUI TRUE; // 展示厂商扫描界面 ui.ModalUI TRUE; // 模态窗口 ui.hParent m_hwndApp; // 父窗口句柄 TW_UINT16 rc DSM_Entry(m_appId, DG_CONTROL, DAT_USERINTERFACE, MSG_ENABLEDS, (TW_MEMREF)ui); if (rc TWRC_SUCCESS) { SetState(4); return true; } return false; }调用成功之后你必须在你的应用程序消息循环或者在对话框的PreTranslateMessage里拦截事件把事件转交给TWAINbool CTwainScanner::ProcessTwainMessage(MSG* pMsg) { TW_UINT16 rc DSM_Entry(m_appId, DG_CONTROL, DAT_EVENT, MSG_PROCESSEVENT, (TW_MEMREF)m_event); if (rc TWRC_DATANOTAVAILABLE) { return false; } // 检查是否收到图像就绪事件 if (m_event.TWMessage MSG_XFERREADY) { // 进入图像传输流程 TransferImage(); } return true; }这里的关键是MSG_PROCESSEVENT。当你把一个Windows消息交给DSM后DSM会判断这个事件是否和数据源相关。如果是它会返回TWRC_SUCCESS并填充TW_EVENT结构体如果无关返回TWRC_DATANOTAVAILABLE这时候你的程序应该忽略这个事件继续正常处理。我强烈建议你重写对话框的PreTranslateMessageBOOL CTwainScannerDlg::PreTranslateMessage(MSG* pMsg) { if (m_scanner.IsTwainActive()) { if (m_scanner.ProcessTwainMessage(pMsg)) { return TRUE; } } return CDialogEx::PreTranslateMessage(pMsg); }当厂商UI是模态对话框时消息循环其实由驱动自己管理此时MSG_PROCESSEVENT不一定需要同步调用。但为了稳妥我仍然在PreTranslateMessage里做统一转发这样可以兼容更多设备驱动。3.5 第四步接收图像数据NATIVE模式当用户在扫描界面点击“扫描”按钮后TWAIN状态机进入State 6你的程序会收到MSG_XFERREADY。此时你必须调用MSG_GET接收图像数据用完了再调用MSG_ENDXFER结束本页传输。初级版我用的是NATIVE传输模式这是最简单的因为它返回给你的是一个TW_HANDLE即Windows全局内存句柄里面装着一份完整DIB数据。void CTwainScanner::TransferImage() { // 1. 获取图像信息 TW_IMAGEINFO imgInfo; memset(imgInfo, 0, sizeof(TW_IMAGEINFO)); TW_UINT16 rc DSM_Entry(m_appId, DG_IMAGE, DAT_IMAGEINFO, MSG_GET, (TW_MEMREF)imgInfo); // 2. 接收图像数据 TW_IMAGE_NATIVE native; memset(native, 0, sizeof(TW_IMAGE_NATIVE)); rc DSM_Entry(m_appId, DG_IMAGE, DAT_IMAGENATIVE, MSG_GET, (TW_MEMREF)native); if (rc TWRC_SUCCESS native.Handle) { // 锁定内存获取DIB指针 LPSTR pDIB (LPSTR)GlobalLock(native.Handle); if (pDIB) { // 处理DIB数据例如显示或保存 ProcessDIB(pDIB); GlobalUnlock(native.Handle); } GlobalFree(native.Handle); } // 3. 结束本页传输 DSM_Entry(m_appId, DG_CONTROL, DAT_PENDINGXFERS, MSG_ENDXFER, (TW_MEMREF)m_pending); }这里有几个关键点需要补充说明。第一MSG_ENDXFER不仅会结束当前传输还可能返回m_pending表示是否还有下一页数据。如果你扫描的是多页文档数据源会处于State 6你可以循环读取下一页。当m_pending.Count 0时传输彻底结束。第二DIB数据的解析。DIB以BITMAPFILEHEADER的14字节开头不对TWAIN返回的DIB中前14字节是BITMAPFILEHEADER紧接着是BITMAPINFOHEADER然后是像素数据。在判断图像宽高时你必须从BITMAPINFOHEADER里读取而不是简单认为DIB就是BMP文件数据。最常见的用法是直接用StretchDIBits绘制void CTwainScanner::ProcessDIB(LPSTR pDIB) { LPBITMAPINFOHEADER lpbmi (LPBITMAPINFOHEADER)(pDIB 14); // 从BITMAPFILEHEADER中获取数据偏移 LPBITMAPFILEHEADER lpbmfh (LPBITMAPFILEHEADER)pDIB; LPVOID bits pDIB lpbmfh-bfOffBits; HDC hdc GetDC(m_hwndApp); StretchDIBits(hdc, 0, 0, lpbmi-biWidth, lpbmi-biHeight, 0, 0, lpbmi-biWidth, lpbmi-biHeight, bits, (LPBITMAPINFO)lpbmi, DIB_RGB_COLORS, SRCCOPY); ReleaseDC(m_hwndApp, hdc); }注意BITMAPINFOHEADER里的高度可能是负值代表自顶向下像素存储这在绘制时不需要特殊处理StretchDIBits会自己识别。第三传输模式的差异。TWAIN支持NATIVE、FILE和MEMORY三种传输模式。初级版用NATIVE最顺手它一次性把所有数据放到一个全局内存块里适合单页扫描。MEMORY模式则按块传输适合大图或内存受限的场景但这需要处理MSG_GET的循环调用属于进阶内容。3.6 第五步关闭数据源与清理扫描结束之后一定要按状态机顺序逐步释放不能直接跳过某个状态。void CTwainScanner::CloseSource() { // 如果UI还开着先禁用 if (m_state 4 || m_state 5 || m_state 6) { TW_USERINTERFACE ui; memset(ui, 0, sizeof(TW_USERINTERFACE)); DSM_Entry(m_appId, DG_CONTROL, DAT_USERINTERFACE, MSG_DISABLEDS, (TW_MEMREF)ui); } // 如果还有待处理的数据取消 TW_PENDINGXFERS pending; memset(pending, 0, sizeof(TW_PENDINGXFERS)); DSM_Entry(m_appId, DG_CONTROL, DAT_PENDINGXFERS, MSG_RESET, (TW_MEMREF)pending); // 关闭数据源 DSM_Entry(m_appId, DG_CONTROL, DAT_IDENTITY, MSG_CLOSEDS, (TW_MEMREF)m_srcId); } void CTwainScanner::CloseDSM() { DSM_Entry(m_appId, DG_CONTROL, DAT_PARENT, MSG_CLOSEDSM, (TW_MEMREF)m_hwndApp); }很多初学者只调MSG_CLOSEDS忽略了MSG_RESET导致下一次扫描时驱动还停留在上一次的传输状态扫描数据错乱。这是一个容易被忽略的坑。每次扫描结束应该先调用MSG_RESET把待处理数据清空再MSG_CLOSEDS关闭数据源。4. 常见问题与排查技巧实录4.1 返回值与错误码速查TWAIN调用几乎每一个函数都会返回一个TW_UINT16返回值核心返回值包括返回值含义常见原因TWRC_SUCCESS调用成功无TWRC_FAILURE调用失败参数错误、状态机不对、驱动异常TWRC_CANCEL用户取消用户关闭扫描UI或取消选择TWRC_DATANOTAVAILABLE数据不可用事件并非TWAIN消息TWRC_NOTDSSUPPORTED操作不支持数据源不支持该能力TWRC_BUSY设备忙上一操作未完成TWRC_XFERDONE传输完成已获取全部数据当遇到TWRC_FAILURE时马上调用MSG_GETSTATUS拿到TW_STATUS里的ConditionCode。ConditionCode对应的含义在twain.h中有定义例如TWCC_OUTOFSERVICE表示设备离线TWCC_SEQERROR表示状态机调用顺序错误TWCC_BADVALUE表示参数值不合法。我在调试时最常遇到的就是TWCC_SEQERROR比如在没有打开数据源的情况下直接调用MSG_ENABLEDS。这种错误提示虽然不好看懂但看到SEQERROR就应该回去检查状态机逻辑。4.2 消息循环透传不生效怎么办有朋友问我ProcessTwainMessage返回一直是DATANOTAVAILABLE扫描仪UI弹不出来。这种情况最常见的原因是窗口句柄不对。MSG_ENABLEDS里TW_USERINTERFACE.hParent指定的窗口和你实际接收消息的窗口必须是同一个。如果你在类里保存了一个全局主窗口句柄但在另一个子窗口里调扫描就很容易出现消息发给了父窗口但子窗口却调了MSG_PROCESSEVENT的情况。解决办法在InitTwain和EnableSourceUI里传递同一个固定的m_hwndApp全程不要换。另外如果你是在控制台程序里做实验没有消息循环那扫描UI是无论如何也弹不出来的。TWAIN依赖Windows消息系统纯命令行程序需要自己创建窗口并运行消息循环不能偷懒。4.3 图像数据是反的或者颜色不对扫描出的图像上下颠倒最常见的原因是BITMAPINFOHEADER.biHeight为正值时DIB是自底向上的存储方式。很多驱动输出的是正向的DIB有些则是负高度需要自行判断。如果颜色不对比如整体偏蓝或偏绿检查biBitCount和像素格式。TWAIN默认可能是灰度8位也可能是24位真彩驱动不同差异很大。在扫描UI里让用户选择“彩色照片”模式然后再看biClrUsed和biCompression字段。遇到灰度图像时像素数据是8位索引值必须配合调色板颜色表才能正确显示。直接把灰度DIB当作24位图去绘制必然会出现颜色错乱。4.4 二次扫描崩溃的问题很多人的程序第一次扫描完全正常关闭扫描仪后再扫就崩溃。这个十有八九是GlobalFree重复释放导致的。MSG_GET返回的native.Handle由应用程序负责释放如果你在ProcessDIB里释放了一次回到外层又释放一次第二次必崩。我的处理方式是约定ProcessDIB只做数据解析和绘制绝不在内部释放句柄句柄的释放统一在TransferImage函数的末尾做。这样逻辑清晰也方便排查。还有一类崩溃和GlobalLock/GlobalUnlock次数不匹配有关。锁定一次必须相应解锁一次否则系统内存状态会被破坏建议用RAII或者明确的try-catch-finally结构包住。4.5 某些扫描仪驱动不弹UI老款扫描仪或者精简版驱动会出现设置ShowUI TRUE仍然不弹界面的情况。这时可以先检查m_srcId是否正确再检查驱动是否安装完整。如果驱动真的不弹UI程序会直接进入MSG_XFERREADY状态吗不一定有些驱动在MSG_ENABLEDS之后会先自动开始扫描并直接传输。针对这种异常驱动初级版没有很好的办法处理只能先判断是否是驱动版本问题或者考虑升级到代码控制参数的无UI模式。5. 进阶路线一瞥初级版跑通之后你会面对两张“深水区”的地图参数控制与多页传输。参数控制的本质是使用MSG_GET和MSG_SET配合TW_CAPABILITY结构体操作数据源能力。比如设置分辨率就要构造TW_CAPABILITY cap; cap.Cap ICAP_XRESOLUTION; cap.ConType TWON_ONEVALUE; cap.hContainer GlobalAlloc(GHND, sizeof(TW_ONEVALUE));然后锁定容器内存把分辨率值写入浮点成员再调用MSG_SET。这里牵扯容器类型判断、数据类型的转换以及不同驱动对分辨率精度要求的差异需要花不少时间去适配。多页传输则要处理好MSG_ENDXFER返回的TW_PENDINGXFERS.Count如果大于0继续循环调用MSG_GET。注意每次获取前都要等到新的MSG_XFERREADY消息不能盲目连续MSG_GET否则容易把驱动搞挂。再往后你会接触到MEMORY传输模式按块接收数据、拼接缓冲区从而突破系统全局内存大小的限制扫描超长纸张或高分辨率大图。那时你对TWAIN协议的理解又会进入一个新层次。6. 写在最后折腾TWAIN这套协议我觉得最重要的不是背下所有结构体而是建立起“状态机消息透传数据生命周期”这三个底层心智模型。我见过很多人一上来就贴上大段代码却没理解为什么MSG_PROCESSEVENT要放在消息循环里为什么数据源UI是模态的为什么DIB的句柄要自己释放——一旦代码报错就无从下手只能靠瞎试。初级版先把这几个根本问题吃透后续无论接到什么牌子的扫描仪、什么复杂度的需求你都能顺着协议框架往下拆而不是被某个厂商的特殊行为带偏。先从弹出一个厂商扫描界面、拿到一张DIB图开始吧剩下的路走起来自然就清楚了。
返回列表