














本文来自 XX 档案数字化系统研发团队(专栏第 11 篇)。
我们做纸质档案数字化、电子档案管理系统、数字档案室建设,文中所有数据与踩坑均来自真实项目。
有档案数字化需求或技术交流,欢迎私信/评论区留言。
扫描是整个数字化流水线的起点,也是人机耦合最重的环节:操作员放纸、扫描仪进纸、出片、质检、换下一卷。30 万页的项目,如果单台扫描仪平均一天只能稳定产出 8000~12000 页,意味着一个扫描工位要跑 25~40 天。
这个环节的软件工程问题不是"扫描本身",而是:
这篇讲我们封装扫描采集驱动的实践,重点在 TWAIN(Windows 生态)和 SANE(Linux 生态) 的抽象层设计。
| 维度 | TWAIN | SANE |
|---|---|---|
| 生态 | Windows 主流(商用扫描仪标配) | Linux/macOS 生态 |
| 接口 | DLL 回调 + 消息机制,状态机复杂 | C API,sane_open/start/read 同步风格 |
| 多线程 | 同进程单实例,访问要串行化 | 相对自由,但后端设备多不支持并发 |
| 驱动质量 | 厂商驱动差异巨大(有些驱动会崩) | 依赖厂商 SANE 后端,A3 高速机支持参差 |
我们的部署环境是 Windows 为主(档案室基本都是 Windows + 国产化改造后的信创环境另说),所以 TWAIN 是主通道,SANE 作为信创/国产化环境的备选。抽象层的目标:上层业务代码不感知底层是 TWAIN 还是 SANE。
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 # 自动进纸器
关键设计决策:
np.ndarray 或 ScanError;duplex=True 翻译成各驱动的真实配置,上层永远只说业务语言。TWAIN 依赖 Windows 消息泵(DSM_Entry 需要 HWND 和消息循环)。很多团队在后台线程里直接调 TWAIN,结果扫描仪没反应或崩溃。我们的做法:
部分驱动(尤其老款)在上一批任务异常中断后,内部状态不干净,直接 reopen 会返回各种诡异错误。我们的兜底:close → 强制释放 → 等待 500ms → 重新 open → 重试 3 次。重试还不行就标记设备离线,通知操作员重启扫描仪电源——这看起来"土",但非常管用。
档案里 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"
几个调度层的关键决策:
page_index 每页落盘,扫描仪中途坏了,任务重新投递时从断点继续,不重扫已完成的页——30 万页项目里这个功能省下的工作量是巨大的;acquire_one 内部检测整页灰度方差低于阈值(如标准差 < 5)即返回 None,自动丢弃空白页并在批次里记录;采集环节的质检前置,能省掉后面海量返工。我们在调度层实时计算:
监控看板(每 10 秒刷新):
设备A:12340页/今日 · 速度 41页/min · 质量分 96.2 · 重扫率 1.8%
设备B:11890页/今日 · 速度 39页/min · 质量分 94.7 · 重扫率 2.5% ← 关注
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。