问题的来源

今天在做一个OCR文字识别的小程序。简单来说就是把剪切板里的图片转换为BASE64编码,然后传到腾讯云的图像识别API上,序列化返回结果,最后写入剪切板。

其中,获取剪切板图像用的是Pillow模块,获取base64编码则是直接读取保存的png图片。代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
"""模块仅对windows系统有效"""
from PIL import ImageGrab
import base64
import os

"""对Windows有效"""
img = ImageGrab.grabclipboard() # 获取剪贴板中的图片;如果是文字则会报错,请务必注意
if not img: # 相当于捕获了None
print("没有图片")
exit(1)

img.save('temp.png') # 这里隐式地在磁盘上创建了一个文件,并且将图片按照给定的格式写入

with open('temp.png', 'rb') as f: # 读取临时文件
base64Data = base64.b64encode(f.read()) # 读取二进制
base64Data = base64Data.decode() # 转为字符串

os.remove('temp.png') # 显式删除临时文件

pillow模块没有直接提供base64转码的方法,同样地,pillow模块并没有提供一个文件操作对象。所有经过pillow加工的图片都是以PIL.Image对象存储在栈里的,想要转码成为Base64就得拿到这个图片的格式化二进制字节串。

这样的设计也不是pillow的问题,因为图片的格式有很多种,为了处理的方便只有在保存图片之前才会要求提供格式,上游的api也能够接收多种格式的图片进行识别。想要把PIL和base64连接到一起,貌似只有使用image的save方法来保存一个临时文件。

但事实真的如此吗?

更Pythonic的解决方法

Python使用的是“鸭子类型”,也就是说文件对象的底层不必一定是一个磁盘上的文件,也可以是一个内存高速缓存甚至是一个数据库里的数据,只要封装上了文件的各个方法(也可以说是接口),上层在调用的时候就可以当作是一个文件来调用。

所幸的是Python中实现这样的“文件”的库叫做io,open()返回的就是一个io库中的文件对象,对象的类别取决于打开的模式。

更巧的是,PIL库是支持将图片写入文件对象。
文档原文

Image.save(fp, format=None, **params)

Saves this image under the given filename. If no format is specified, the format to use is determined from the filename extension, if possible.

Keyword options can be used to provide additional instructions to the writer. If a writer doesn’t recognize an option, it is silently ignored. The available options are described in the image format documentation for each writer.

You can use a file object instead of a filename. In this case, you must always specify the format. The file object must implement the seek, tell, and write methods, and be opened in binary mode.

简单来说就是save方法传入的fp参数可以是一个文件对象,只要这个文件对象有seektellwrite方法即可。
而在io库中,BytesIO()对象刚好就是我们需要的。创建一个BytesIO很简单,只要调用构造函数BytesIO()即可,所有方法可以查看官方文档,现在我们只需要用到getvalue()即可。getvalue方法会返回这个缓存对象的所有二进制内容。

注意

  • BytesIO是一个类似打开之后的二进制文件的对象,而正是因为它只是一个对象,所以它:
    1. 仅存在于内存中,高速读写
    2. 和程序中的其他变量一样受GC控制,程序结束便一同消亡
    3. 对这个对象的操作全程没有磁盘io参与
  • BytesIO和文件IO不一样,它的读取数值的方法是getvalue(),而不是read()
  • BytesIO的生命周期受GC控制,如果想要自主控制生命周期可以使用with BytesIO as f:这样的语句进行控制

改进之后的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from PIL import ImageGrab
import base64
from io import BytesIO

"""模块仅对windows系统有效"""

img = ImageGrab.grabclipboard() # 获取剪贴板中的图片
if not img:
print("没有图片")
exit(1)

with BytesIO() as f: # 创建文件对象
img.save(f, format='png') # 将图片保存到对象中
base64Data = base64.b64encode(f.getvalue())
# with 语句结束即销毁BytesIO对象

base64Data = base64Data.decode()