一、问题背景
发现使用labelme直接读取含imageData(将图片bytes数据使用base64编码后的str数据)的json文件时,读上来的图片会发生自动旋转的问题。比如原先是横放的图,读进来后就成竖放的了,图片朝向(orientation)错误。
我们可以用一个形象的例子来说明这种错误:
为什么图片会多此一举地弄一个朝向信息,而不是一步到位呢?
试想这样一个例子:你想给女朋友拍照,她很高,所以你把相机旋转了90度,最后成功拍了个全身照。
可能这个过程你已经重复过无数次,觉得非常自然。但仔细想想,相机的镜头是不会旋转的,也就是说如果人为地把相机旋转90度再拍照,拍下来的女朋友就会是横着站立,而不是正常的竖着站立。就像当你歪着头时,看到的世界也是歪着的。
为了防止这种诡异的现象出现,相机很机智地给图片加上了orientation标签,并“提醒”自己:这张图拍的时候我被旋转了90度,给主人看的时候要记得正过来。
所以这种记录方式的产生其实是两个因素导致的:①相机总是忠实地记录当时的场景,正的就是正的,反的就是反的;②相机希望记录当时的朝向,以方便做任何朝向上的调整。
二、产生原因
那么具体来说,在labelme的图片读取过程中哪里出问题会导致平时自动调整朝向的图片没有调整呢?
接着我们顺着labelme的流程走一遍
-
获取图片信息:labelme在读取json时会有一个if语句,如果有imageData,则直接获取imageData;如果没有imageData,则顺着imagePath找图片文件,然后读取图片文件:
if data["imageData"] is not None: imageData = base64.b64decode(data["imageData"]) if PY2 and QT4: imageData = utils.img_data_to_png_data(imageData) else: # relative path from label file to relative path from cwd imagePath = osp.join(osp.dirname(filename), data["imagePath"]) imageData = self.load_image_file(imagePath)
那么直接读imageData和读文件有什么区别呢?
-
缺少imagaData,使用imagePath:此时直接调用PIL库读取图片文件。注意PIL读取图片上来后,是不会应用exif属性的,也就是说不会应用图片创建之后的朝向改变(包括旋转和对称)操作。
但是!!!
labelme很机智地调用了PIL自带的旋转功能,将图片转成了exif里的朝向。
matplotlib底层也是PIL,也是像这样读上来再调整朝向。
@staticmethod def load_image_file(filename): try: image_pil = PIL.Image.open(filename) except IOError: logger.error("Failed opening image file: {}".format(filename)) return # apply orientation to image according to exif image_pil = utils.apply_exif_orientation(image_pil) with io.BytesIO() as f: ext = osp.splitext(filename)[1].lower() if PY2 and QT4: format = "PNG" elif ext in [".jpg", ".jpeg"]: format = "JPEG" else: format = "PNG" image_pil.save(f, format=format) f.seek(0) return f.read()
综上所述,从imagePath读取图片,是不会出问题的。
-
直接使用imageData:并没有对图片数据做什么处理,关键就是因为没做什么处理,导致该根据exif信息调整朝向的图没有调整。
-
加载图片:最后使用pyqt加载图片:
image = QtGui.QImage.fromData(self.imageData)
看不到pyqt的源码,但它肯定不会去旋转图片就是了,否则之前的代码根本没必要对PIL读上来的图旋转一次。
综上所述,从imageData读取图片,不会应用图片创建之后的调整朝向操作。
三、解决方案
思路很简单:对图片批量处理,用PIL读上来后都应用一次旋转,再保存成新的图片就行了。
附上朝向表:
参数 | 行起始(0行位置) | 列起始(0列位置) | 调整操作 |
---|---|---|---|
1 | 上 | 左 | 0° |
2 | 上 | 右 | 水平翻转 |
3 | 下 | 右 | 180° |
4 | 下 | 左 | 垂直翻转 |
5 | 左 | 上 | 顺时针90°+水平翻转 |
6 | 右 | 上 | 顺时针90° |
7 | 右 | 下 | 顺时针90°+垂直翻转 |
8 | 左 | 下 | 逆时针90° |
细节来了:
从上图的朝向表中我们可以看出,朝向只能是1-8的整数。而实际情况是,有的时候我们获取不到exif属性,更不用说朝向了,或者能获取到朝向,但居然是0。
我们先继续沿用之前举的宝藏的例子:
也就是说如果图片原本就不带有朝向信息,当然不用担心朝向变化的问题了。
一般来说不是相机拍摄的图片自然是不会有exif属性了,比如表情包、画图软件里画的图、随手截的图等。而有exif属性的图经过聊天软件等渠道的处理,也会丢失exif属性。
至于朝向是0的图片,已知部分安卓手机拍摄的照片会出现这种情况。
开干:
import os
import json
import base64
import io
from PIL import Image, ImageOps
def transpose_img(str_img):
"""
根据exif信息旋转图片的函数。
:param str_img: 已经用base64编码之后的图片。
:return: 图片格式、旋转后的Image对象。
"""
# 解码成二进制图片,如果你的图片本来就是二进制的,可以跳过这步
bytes_img = base64.b64decode(str_img)
# 实例化Image对象
new_img = Image.open(io.BytesIO(bytes_img), mode='r')
# 调用exif_transpose()函数,自动根据exif属性旋转
# 这里是情况特殊,如果你不需要图片格式,可以不返回.format
return new_img.format, ImageOps.exif_transpose(new_img)
def to_bytes_img(pil_img, fmt):
"""
将图片重新二进制化的函数。
:param pil_img: Image对象。
:fmt: 图片的format属性。
:return: 二进制形式的图片。
"""
secondIO = io.BytesIO()
# 注意这里的quality默认是75,95为最佳;dpi得指定成原图的dpi;这种无图片路径的情况format必须指定;exif也得指定成原图的exif,否则会丢失
pil_img.save(
secondIO,
format=fmt,
quality=95,
dpi=pil_img.info.get('dpi', (96.0, 96.0)),
exif=pil_img.info.get('exif', b""))
return secondIO.getvalue()
补充说明一些细节:
- Image.info可以查看很多图片属性,包括exif属性,但这样得到的exif属性是二进制形式的。也可以使用Image.getexif()获取,但这样获取的属性是不全的。如果想看完整的exif属性,可以使用exifread库或者piexif库;
- 原来的exif在调用exif_transpose()函数时会自动删除第274号orientation朝向属性,因为若原图exif里是90 cw(顺时针旋转90度),转完了肯定不能还显示90 cw。所以省得我们手动改exif了;
- 获取字典中的值都用get,有的图片没有那么多info属性;
- 第二个函数必须传format参数进来,因为图片调整朝向后可能会丢失format属性