数字档案系统研发手记:批量扫描任务调度——TWAIN/SANE 采集驱动的封装实践

档案数字化研发手记(十一):批量扫描任务调度——TWAIN/SANE 采集驱动的封装实践

本文来自 XX 档案数字化系统研发团队(专栏第 11 篇)。
我们做纸质档案数字化、电子档案管理系统、数字档案室建设,文中所有数据与踩坑均来自真实项目。
有档案数字化需求或技术交流,欢迎私信/评论区留言。

一、批量扫描:档案数字化的"第一个瓶颈"

扫描是整个数字化流水线的起点,也是人机耦合最重的环节:操作员放纸、扫描仪进纸、出片、质检、换下一卷。30 万页的项目,如果单台扫描仪平均一天只能稳定产出 8000~12000 页,意味着一个扫描工位要跑 25~40 天

这个环节的软件工程问题不是"扫描本身",而是:

  • 多台扫描仪如何统一管理(品牌不同、驱动不同);
  • 批次任务如何排队、中断、恢复;
  • 扫描仪卡纸、缺纸、报错时如何自动重试而不是傻等人工;
  • 采集质量如何实时把控(漏页、重页、歪斜)。

这篇讲我们封装扫描采集驱动的实践,重点在 TWAIN(Windows 生态)和 SANE(Linux 生态) 的抽象层设计。

二、TWAIN vs SANE:两个世界

维度 TWAIN SANE
生态 Windows 主流(商用扫描仪标配) Linux/macOS 生态
接口 DLL 回调 + 消息机制,状态机复杂 C API,sane_open/start/read 同步风格
多线程 同进程单实例,访问要串行化 相对自由,但后端设备多不支持并发
驱动质量 厂商驱动差异巨大(有些驱动会崩) 依赖厂商 SANE 后端,A3 高速机支持参差

我们的部署环境是 Windows 为主(档案室基本都是 Windows + 国产化改造后的信创环境另说),所以 TWAIN 是主通道,SANE 作为信创/国产化环境的备选。抽象层的目标:上层业务代码不感知底层是 TWAIN 还是 SANE

三、抽象层设计:一个统一的 ScanDevice 接口

class ScanDevice(ABC):
    """扫描设备抽象:屏蔽 TWAIN/SANE 差异"""

    @abstractmethod
    def open(self, device_id: str) -> bool: ...
    @abstractmethod
    def configure(self, params: ScanParams) -> bool:
        """设置分辨率/色彩模式/纸张大小/A3/A4 检测"""
    @abstractmethod
    def acquire_one(self) -> Optional[np.ndarray]:
        """采集一页(阻塞),失败抛 ScanError"""
    @abstractmethod
    def close(self) -> None: ...

class ScanParams:
    def __init__(self, dpi=300, color_mode='gray', paper='A4',
                 duplex=True, adf=True):
        self.dpi = dpi
        self.color_mode = color_mode   # gray / color / bw
        self.paper = paper             # A4 / A3 / auto-detect
        self.duplex = duplex           # 双面
        self.adf = adf                 # 自动进纸器

关键设计决策:

  1. 采集返回的是 numpy 数组而不是文件:扫描 → 预处理 → OCR 是一条流水线,中途不落盘(除非合规要求留原始文件),IO 省 80%;
  2. acquire_one 的阻塞语义:TWAIN 是消息驱动,SANE 是同步读。抽象层内部各自实现"等一张图出来",上层统一收到 np.ndarrayScanError
  3. 参数差异的归一化:不同厂商对"双面"的配置方式完全不同(有的在扫描仪驱动面板里,有的要发特定命令)。抽象层把 duplex=True 翻译成各驱动的真实配置,上层永远只说业务语言

四、TWAIN 封装的三个大坑

坑 1:TWAIN 回调是消息循环的,不能在工作线程里裸调

TWAIN 依赖 Windows 消息泵(DSM_Entry 需要 HWND 和消息循环)。很多团队在后台线程里直接调 TWAIN,结果扫描仪没反应或崩溃。我们的做法:

  • 每个扫描设备绑定一个专用线程 + 隐藏窗口,消息循环常驻;
  • 任务通过队列投递到该线程,结果回传,绝不在主线程或业务线程里碰 TWAIN 句柄
  • 单设备单线程,天然串行,避免 TWAIN 状态机错乱。

坑 2:厂商驱动的"状态机残留"

部分驱动(尤其老款)在上一批任务异常中断后,内部状态不干净,直接 reopen 会返回各种诡异错误。我们的兜底:close → 强制释放 → 等待 500ms → 重新 open → 重试 3 次。重试还不行就标记设备离线,通知操作员重启扫描仪电源——这看起来"土",但非常管用。

坑 3:A3/A4 混合批次

档案里 A3 和 A4 混着来是常态。我们的方案:启用扫描仪的长纸检测/自动纸张大小(auto paper size),拿到的图像尺寸会变化;抽象层在上层统一按"图像宽高比"归一化,预处理和 OCR 只看图像尺寸,不依赖"设定纸张"。

五、任务调度:批量任务的编排

class ScanTask:
    def __init__(self, batch_id, device_id, pages_range, params):
        self.id = f"{batch_id}-{device_id}-{time.time():.3f}"
        self.state = "pending"     # pending/running/done/failed
        self.retry_count = 0
        self.page_index = 0        # 断点续扫位置

class ScanScheduler:
    """多设备任务调度器"""
    def __init__(self, devices: Dict[str, ScanDevice]):
        self.devices = devices
        self.queue = queue.Queue()
        self.worker_threads = [Thread(target=self._worker, args=(dev_id,))
                               for dev_id in devices]

    def _worker(self, dev_id):
        """每台设备一个 worker:设备坏了不阻塞其他设备"""
        while True:
            task = self.queue.get()
            try:
                self._run_task(dev_id, task)
            except ScanError as e:
                self._handle_error(dev_id, task, e)
            finally:
                self.queue.task_done()

    def _run_task(self, dev_id, task):
        """执行一个批次任务,支持断点续扫"""
        device = self.devices[dev_id]
        for i in range(task.page_index, task.pages_range):
            img = device.acquire_one()
            if img is None:
                # 检测到空白页/结束页:任务正常结束
                task.state = "done"
                return
            self._process_page(task, i, img)
            task.page_index = i + 1   # 每页成功即推进断点
        task.state = "done"

几个调度层的关键决策:

  1. 一设备一 worker 一线程:一台卡纸不拖累其他设备;
  2. 断点续扫page_index 每页落盘,扫描仪中途坏了,任务重新投递时从断点继续,不重扫已完成的页——30 万页项目里这个功能省下的工作量是巨大的;
  3. 空白页检测:ADF 双面扫描经常出现空白背面,acquire_one 内部检测整页灰度方差低于阈值(如标准差 < 5)即返回 None,自动丢弃空白页并在批次里记录;
  4. 节流:扫描速度远快于下游 OCR,调度器对每台设备做"滑动窗口限流"(最多积压 N 页),防止内存被未处理的图像占爆。

六、质量实时监控

采集环节的质检前置,能省掉后面海量返工。我们在调度层实时计算:

  • 页数统计:预期页数 vs 实际页数(ADF 漏页报警);
  • 图像质量分:模糊度(Laplacian 方差)、倾斜角、黑边比例,实时打分,低于阈值的页自动进"重扫队列";
  • 设备健康度:连续错误次数、平均单页耗时,用于预判设备故障(某设备耗时突然翻倍,大概率该保养了)。
监控看板(每 10 秒刷新):
设备A:12340页/今日 · 速度 41页/min · 质量分 96.2 · 重扫率 1.8%
设备B:11890页/今日 · 速度 39页/min · 质量分 94.7 · 重扫率 2.5% ← 关注

七、小结

  • 扫描采集抽象层(TWAIN/SANE 统一接口)是批量数字化的地基;
  • TWAIN 必须专用线程 + 消息循环,厂商驱动要容错重试;
  • 任务调度核心是断点续扫 + 一设备一 worker + 限流
  • 质检前置 + 设备健康监控,让 30 万页批跑不掉链子。
posted on 2026-09-03 15:59  程序员李铁牛  阅读(5)  评论(0)    收藏  举报