Python使用Twain协议调用扫描仪

安装

1
2
pip install pytwain
pip install Pillow

官方方式

注意

调用扫描要在主进程中执行,否则无法获取扫描的回调。

测试

这种方式会在32位的Python下加载twain_32.dll,在64位的Python下加载twaindsm.dll,在32和64位下均能正常运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import logging
import os
import tkinter
from datetime import datetime
from io import BytesIO
from tkinter import ttk

import PIL.Image
import PIL.ImageTk
import twain
from twain.lowlevel import constants


def select():
with twain.SourceManager(root) as sm:
src = sm.open_source()
if src:
scanner_name = src.name
print(f"选择的扫描仪名称是: {scanner_name}")
else:
print("未选择任何扫描仪。")


def scan():
show_ui = False
dpi = 200
scan_num = 1
with twain.SourceManager(root) as sm:
sd = sm.open_source("HUAGOSCAN G200 TWAIN")
more = 1
if sd:
if dpi:
sd.set_capability(
constants.ICAP_XRESOLUTION,
constants.TWTY_FIX32, dpi)
sd.set_capability(
constants.ICAP_YRESOLUTION,
constants.TWTY_FIX32, dpi)
sd.request_acquire(show_ui=show_ui, modal_ui=True)
sd.modal_loop()
while more:
(handle, has_more) = sd.xfer_image_natively()
more = has_more
print(f"has_more:{has_more}")
bmp_bytes = twain.dib_to_bm_file(handle)
img = PIL.Image.open(BytesIO(bmp_bytes), formats=["bmp"])
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
file_name = f"imgs/scan_{timestamp}_{scan_num:03d}.jpg"
img.save(file_name, format='jpeg')
scan_num += 1
else:
print("User clicked cancel")


logging.basicConfig(level=logging.INFO)
root = tkinter.Tk()
frm = ttk.Frame(root, padding=10)
frm.grid()
ttk.Button(frm, text="选择扫描仪", command=select).grid(column=0, row=0)
ttk.Button(frm, text="开始扫描", command=scan).grid(column=0, row=1)

if not os.path.exists("imgs"):
os.makedirs("imgs")
root.mainloop()

工具类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# This Python file uses the following encoding: utf-8
import os
from datetime import datetime
from io import BytesIO
from typing import Callable, List, Optional

import PIL.Image
import PIL.ImageTk
import twain
from twain.lowlevel import constants

from common.global_vars import GlobalVars


def scan_paper(
save_folder: str,
show_ui: bool = False,
dsm_name: Optional[str] = None,
dpi: float = 200,
duplex: bool = True, # 是否为双面扫描
on_scan_img: Optional[Callable[[str], None]] = None,
on_scan_finish: Optional[Callable[[], None]] = None,
on_scan_error: Optional[Callable[[Exception], None]] = None,
):
print(
f"save_folder:{save_folder} show_ui:{show_ui} dsm_name:{dsm_name} dpi:{dpi}")
hwnd = GlobalVars.root_window.winId()

with twain.SourceManager(hwnd) as sm:
sd = sm.open_source(dsm_name)
scan_page_num = 1
more = 1
if sd:
if dpi:
sd.set_capability(
constants.ICAP_XRESOLUTION,
constants.TWTY_FIX32, dpi)
sd.set_capability(
constants.ICAP_YRESOLUTION,
constants.TWTY_FIX32, dpi)
# 双面扫描
sd.set_capability(
constants.CAP_DUPLEXENABLED,
constants.TWTY_BOOL, duplex)

sd.request_acquire(show_ui=show_ui, modal_ui=True)
sd.modal_loop()

try:
while more:
(handle, has_more) = sd.xfer_image_natively()
more = has_more

bmp_bytes = twain.dib_to_bm_file(handle)
img = PIL.Image.open(BytesIO(bmp_bytes), formats=["bmp"])
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
file_name = f"scan_{timestamp}_{scan_page_num:03d}.jpg"
full_path = os.path.join(save_folder, file_name)
print(f"full_path:{full_path}")
if not duplex or scan_page_num % 2 == 1:
img.save(full_path, format='jpeg')
else:
rotated_img = img.rotate(180)
rotated_img.save(full_path, format='jpeg')

if on_scan_img is not None:
on_scan_img(full_path)
scan_page_num += 1
if on_scan_finish is not None:
on_scan_finish()
except twain.exceptions.DSTransferCancelled:
# 捕获用户取消扫描的异常
print("User cancelled the scan.")
if on_scan_error is not None:
on_scan_error(Exception("用户取消"))

except Exception as e:
if on_scan_error is not None:
on_scan_error(e)
else:
if on_scan_error is not None:
on_scan_error(Exception("用户取消"))


def select_device() -> List[str]:
hwnd = GlobalVars.root_window.winId()
name_arr = []
sm = None
try:
sm = twain.SourceManager(hwnd)
s_list = sm.GetSourceList()
for idx, name in enumerate(s_list):
name_arr.append(name)
finally:
if sm is not None:
sm.close()
return name_arr

Qt Quick中传入parent_window

twain.SourceManager要传入parent_window,但是源码中是这样判断的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
self._hwnd = 0
if utils.is_windows():
if hasattr(parent_window, "winfo_id"):
# tk window
self._hwnd = parent_window.winfo_id()
elif hasattr(parent_window, "GetHandle"):
# wx window
self._hwnd = parent_window.GetHandle()
elif hasattr(parent_window, "window") and hasattr(
parent_window.window, "handle"
):
# gtk window
self._hwnd = parent_window.window.handle
elif parent_window is None:
self._hwnd = 0
else:
self._hwnd = int(parent_window)

可以看到如果我们的窗口是Tk, Wx or Gtk window,则是没问题的

但是Qt的窗口是PySide2.QtQuick.QQuickWindow

1
root_window = engine.rootObjects()[0]

所以直接传窗口对象是不行的

可以传入句柄

1
2
root_window = engine.rootObjects()[0]
hwnd = root_window.winId()

使用TWAINDSM

这种方式我在Qt Quick中如果Python是64位是没问题的,但是32位的时候就不行

扫描测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
# This Python file uses the following encoding: utf-8

import json
import os
from datetime import datetime
from typing import Callable, Dict, List, Literal, Optional, Tuple

import twain
from twain import exceptions
from twain.lowlevel import constants

from utils.z_sys_utils import get_sys_bit
from utils.zlog import logger


def acquire(
save_folder: str,
dpi: Optional[float] = None,
pixel_type: Optional[Literal["bw", "gray", "color"]] = "color",
bpp: Optional[int] = None,
frame: Optional[Tuple[float, float, float, float]] = None,
show_ui: bool = False,
dsm_name: Optional[str] = None,
modal: bool = True,
on_scan_img: Optional[Callable[[str], None]] = None,
on_scan_finish: Optional[Callable[[], None]] = None,
on_scan_error: Optional[Callable[[Exception], None]] = None,
) -> Optional[List[Dict]]:
sysbit = get_sys_bit()
logger.info(
f"开始acquire函数,参数: path={save_folder}, dpi={dpi}, pixel_type={pixel_type}, frame={frame}")
if pixel_type:
pixel_type_map = {
"bw": constants.TWPT_BW,
"gray": constants.TWPT_GRAY,
"color": constants.TWPT_RGB,
}
twain_pixel_type = pixel_type_map[pixel_type]
logger.info(f"设置像素类型: {pixel_type}")
else:
twain_pixel_type = None
logger.info(f"使用扫描仪: {dsm_name}")
sm = twain.SourceManager(0, dsm_name=f"dll\\twain\\{sysbit}\\TWAINDSM.dll")
logger.info("创建SourceManager对象")
page_number = 0
full_path = ""
try:
sd = sm.open_source(dsm_name)
if not sd:
logger.error("无法打开数据源")
if on_scan_error is not None:
on_scan_error(Exception("无法打开扫描仪"))
return None
logger.info("成功打开数据源")
try:
if twain_pixel_type:
sd.set_capability(
constants.ICAP_PIXELTYPE,
constants.TWTY_UINT16, twain_pixel_type)
sd.set_capability(
constants.ICAP_UNITS,
constants.TWTY_UINT16, constants.TWUN_INCHES)
# 是否双面打印
sd.set_capability(
constants.CAP_DUPLEXENABLED,
constants.TWTY_BOOL, True)
if bpp:
sd.set_capability(
constants.ICAP_BITDEPTH,
constants.TWTY_UINT16, bpp)
if dpi:
sd.set_capability(
constants.ICAP_XRESOLUTION,
constants.TWTY_FIX32, dpi)
sd.set_capability(
constants.ICAP_YRESOLUTION,
constants.TWTY_FIX32, dpi)
if frame:
try:
sd.set_image_layout(frame)
except exceptions.CheckStatus:
logger.warning("设置图像布局失败")
logger.info("设置扫描参数完成")
res: List[Dict] = []

def before(img_info: Dict) -> str:
nonlocal page_number
nonlocal full_path
page_number += 1
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
file_name = f"scan_{timestamp}_{page_number:03d}.png"
full_path = os.path.join(save_folder, file_name)
res.append(img_info)
logger.info(f"准备保存第 {page_number} 页,文件路径: {full_path}")
return full_path

def after(more: int) -> None:
logger.info(f"保存完毕-第 {page_number} 页,文件路径: {full_path}")
if os.path.exists(full_path):
if on_scan_img is not None:
on_scan_img(full_path)
if more == 0:
logger.info("扫描完成,没有更多页面")

try:
sd.acquire_file(before=before, after=after,
show_ui=show_ui, modal=modal)
logger.info("扫描结束")
if on_scan_finish is not None:
on_scan_finish()
except Exception as e:
logger.info("用户取消了扫描:"+str(e))
if on_scan_error is not None:
on_scan_error(e)
return None
finally:
sd.close()
logger.info("关闭数据源")
except Exception as e:
on_scan_error(e)
finally:
sm.close()
logger.info("关闭SourceManager")
logger.info(f"扫描结果: {json.dumps(res)}")
return res


def select_device() -> List[str]:
sysbit = get_sys_bit()
name_arr = []
sm = None
try:
sm = twain.SourceManager(
0, dsm_name=f"dll\\twain\\{sysbit}\\TWAINDSM.dll")
s_list = sm.GetSourceList()
for idx, name in enumerate(s_list):
name_arr.append(name)
finally:
if sm is not None:
sm.close()
return name_arr

测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
output_directory = r"D:\test"
if not os.path.exists(output_directory):
os.makedirs(output_directory)
logger.info(f"创建输出目录: {output_directory}")


def scan():
logger.info("开始扫描过程")
try:
outpath = output_directory
logger.info(f"输出路径: {outpath}")
dsm_name = "HUAGOSCAN G200 TWAIN"
result = acquire(
outpath,
dsm_name=dsm_name,
dpi=200,
pixel_type="color",
show_ui=False,
)
except Exception as e:
logger.error(f"扫描过程中发生错误: {str(e)}")
else:
if result:
logger.info(f"扫描成功,图像保存在: {outpath}")

驱动无法调用

这种情况可能是TWAINDSM.dll的位数不匹配。

twain-dsm-2.5.1.zip
https://pan.baidu.com/s/1kM7Pbvzh2QVQyKlAnTNxSA?pwd=w7id

我这里DLL放在项目根目录下的dll\twain\32\TWAINDSM.dll

根据程序是32位还是64位选择性加载即可。

1
sm = twain.SourceManager(0, dsm_name=r"dll\twain\32\TWAINDSM.dll")

打包时

添加项目下dll

1
pyinstaller main.py -y --noconsole --name="xhscanner-client" --icon="logo.ico" --add-binary "dll\twain\32\TWAINDSM.dll;."

上面这种方式TWAINDSM.dll会放在_internal目录中,这样虽然路径变了,但是_internal根路径的DLL因为可以正常加载,所以可以正常使用。

获取Python位数

这是获取Python运行环境的位数,也就是我们程序的位数,不是系统的位数。

z_sys_utils.py

1
2
3
4
5
6
7
8
9
10
import sys


def get_sys_bit():
if sys.maxsize == 2**31 - 1:
return 32
elif sys.maxsize == 2**63 - 1:
return 64
else:
return 32

完整示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import logging
import os
import tkinter
from datetime import datetime
from io import BytesIO
from tkinter import ttk

import PIL.Image
import PIL.ImageTk
import twain
from twain.lowlevel import constants


def select():
with twain.SourceManager(root) as sm:
src = sm.open_source()
if src:
scanner_name = src.name
print(f"选择的扫描仪名称是: {scanner_name}")
else:
print("未选择任何扫描仪。")


def scan():
show_ui = False
sanner_source_name = "HUAGOSCAN G200 TWAIN"
dpi = 200
scan_num = 1
with twain.SourceManager(root) as sm:
sd = sm.open_source(sanner_source_name)
more = 1
if sd:
if dpi:
sd.set_capability(
constants.ICAP_XRESOLUTION,
constants.TWTY_FIX32, dpi)
sd.set_capability(
constants.ICAP_YRESOLUTION,
constants.TWTY_FIX32, dpi)
# 是否双面打印
sd.set_capability(
constants.CAP_DUPLEXENABLED,
constants.TWTY_BOOL, True)
sd.request_acquire(show_ui=show_ui, modal_ui=True)
while more:
(handle, has_more) = sd.xfer_image_natively()
more = has_more
print(f"has_more:{has_more}")
bmp_bytes = twain.dib_to_bm_file(handle)
img = PIL.Image.open(BytesIO(bmp_bytes), formats=["bmp"])
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
file_name = f"imgs/scan_{timestamp}_{scan_num:03d}.jpg"
img.save(file_name, format='jpeg')
scan_num += 1
else:
print("User clicked cancel")


logging.basicConfig(level=logging.INFO)
root = tkinter.Tk()
frm = ttk.Frame(root, padding=10)
frm.grid()
ttk.Button(frm, text="选择扫描仪", command=select).grid(column=0, row=0)
ttk.Button(frm, text="开始扫描", command=scan).grid(column=0, row=1)

if not os.path.exists("imgs"):
os.makedirs("imgs")
root.mainloop()