Однажды столкнулся с магическим багом, причину возникновения которого, не удавалось отыскать почти на протяжении месяца. Да что там причину, его даже воспроизвести никак не удавалось. Заказчик постоянно жаловался что изображения обрезаются неправильно, да при том ещё непредсказуемо поворачиваются…
Неуловимая ошибка
Суть проекта заключалась в том, что пользователи выкладывают фотографии, сопровождая их коротенькими историями, далее модераторы проверяют контент на соответствие тематике ресурса, обрезают фотографии по необходимому соотношению сторон и публикуют эти истории.
Буквально с первых же дней выхода проекта в продакшн, начали поступать жалобы от модераторов. Жаловались на то, что фотографии обрезаются не так как это ожидается, иногда появляются черные полоски, иногда фотографии внезапно поворачиваются, иногда вообще не происходит каких либо изменений после обрезки.
Для обрезки изображений использовалась библиотека django-image-cropping, которая позволяет в админке джанго обрезать изображения при помощи jQuery
плагина Jcrop.
Ложные обвинения
Поскольку ошибку никак не удавалось воспроизвести, стали полагать, что причина прячется где то в клиентской части. За две с половиной недели поиска ошибки, были перепробованы чуть менее чем все комбинации различных браузеров и операционных систем, включая устаревшие версии браузеров. Но воспроизвести ошибку все никак не удавалось.
Тем неменее, усердное тестирование обрезки изображения в различных браузерах выявило сопутствующую ошибку. От браузера, кстати, независящую. Продакшен сервер был оптимизирован под большое количество посетителей. Сам проект представлял из себя регенерируемый, статический сайт, кешированный nginx
ом; небольшое api
для добавления и поиска историй; и административный интерфейс для премодерации историй и перегенерации статического контента.
Так получилось, что nginx
слишком усердно справлялся с кешированием, и достаточно часто после обрезки изображения в админке показывал старое (не обрезанное изображение), после чего модератор снова и снова обрезал картинку, а в результате исходное изображение резалось по несуществующим координатам. Соответственно на выходе получалось изкромсаное изображение с черными полосками и когда кеш обновлялся то это становилось заметно.
Данную ошибку с кеширование легко исправить введя случайный GET
параметр в адресе изображения перед выводом его в админке. Примерно так:
from random import randint
rand_url = lambda url: '{url}?{num}'.format(url=url, num=randint(10000, 99999))
# ...
return {
# ...
'data-thumbnail-url': rand_url(thumbnail(image).url)
# ...
}
Вследствии чего, проблема отображения старого изображения после обрезки исчезла. В админке выводился следующий html
код отображения изображения:
<img id="id_cropping-image"
src="/src/img/9b/c6/9bc62cfaaf70be365de538b415cbb8df.jpg.800x800_q85_detail_upscale.jpg?80339"
style="width: 800px; height: 634px;">
Ошибка найдена и ликвидирована. Больше повторятся не должна. Можно торжествовать победу.
Начинаем сначала
Буквально через неделю, после сабмита изменений в продакшн, от модераторов вновь поступила жалоба на некорректную обрезку изображения. К счастью, на этот раз они явно указали на каком именно изображении воспроизводится некорректная обрезка и переслали данное изображение по электронной почте.
Открыв письмо, я открыл картинку в новой вкладке и сохранил её. Начал тестировать в vagrant
е — ошибка не воспроизводится, изображение обрезается как нужно, ничего не поворачивается и не возникают черные полосы. Проверил эту же картинку на продакшн сервере — не воспроизводится. Хитрость оказалась в том, что нужно было картинку именно скачать, а не сохранить из письма. Получив исходное изображение, ошибка стала регулярно воспроизводится.
Exif метаданные
Поскольку существенную разницу поведения изображения вызывало сохранение и скачивание, а так же — тот факт, что после сохранения изображения в графическом редакторе ошибка прекращала воспроизводится. Натолкнуло на мысль, о том, что в данном изображении содержится дополнительная информация, которая при сохранении теряется.
В графических изображениях дополнительную метаинформацию можно сохранять в формате EXIF. Где среди прочего может содержаться информация об ориентации изображения. Выведя exif
данного файла, сразу стало понятно почему ошибка воспроизводится именно на нем.
>>> from PIL import Image
>>> from PIL.ExifTags import TAGS
>>>
>>> im = Image.open('photo.jpg')
>>> exif = im._getexif()
>>>
>>> data = {}
>>>
>>> for tag, value in exif.items():
... decoded = TAGS.get(tag, tag)
... data[decoded] = value
...
>>>
>>> import pprint
>>> pp = pprint.PrettyPrinter(indent=4)
>>>
>>> pp.pprint(data)
{ 'ApertureValue': (4281, 1441),
'ColorSpace': 1,
'ComponentsConfiguration': '\x01\x02\x03\x00',
'DateTime': u'2012:09:08 18:10:40',
'DateTimeDigitized': u'2012:09:08 18:10:40',
'DateTimeOriginal': u'2012:09:08 18:10:40',
'ExifImageHeight': 1536,
'ExifImageWidth': 2048,
'ExifOffset': 206,
'ExifVersion': '0221',
'ExposureMode': 0,
'ExposureProgram': 2,
'ExposureTime': (1, 15),
'FNumber': (14, 5),
'Flash': 32,
'FlashPixVersion': '0100',
'FocalLength': (77, 20),
'GPSInfo': { 1: u'N',
2: ((**, **), (****, ****), (*, *)),
3: u'E',
4: ((**, **), (****, ****), (*, *)),
5: '\x00',
6: (25453, 182),
7: ((11, 1), (10, 1), (3588, 100)),
16: u'M',
17: (44274, 191)},
'ISOSpeedRatings': 100,
'Make': u'Apple',
'MeteringMode': 1,
'Model': u'iPhone 3GS',
'Orientation': 6,
'ResolutionUnit': 2,
'SceneCaptureType': 0,
'SensingMethod': 2,
'Sharpness': 1,
'Software': u'5.1.1',
'SubjectLocation': (1023, 767, 614, 614),
'WhiteBalance': 0,
'XResolution': (72, 1),
'YCbCrPositioning': 1,
'YResolution': (72, 1)}
>>>
Как видно из exif
данных, изображением является фотография сделанная на iPhone 3GS
в сентябре 2012 года. Но это совершенно не важно, главным является информация об ориентации изображения: Orientation 6
.
Exif Orientation
тег описывает ориентацию изображения при отображении, используя значения от 1 до 8:
1 2 3 4 5 6 7 8
###### ###### ## ## ########## ## ## ##########
## ## ## ## ## ## ## ## ## ## ## ##
#### #### #### #### ## ########## ########## ##
## ## ## ##
## ## ###### ######
Наглядно на примере фотокамеры, могут возникать 4 варианта тега ориентации:
PIL & Exif
При сохранении изображения с помощью PIL
все метаданные exif
стираются, поэтому сохраненная картинка без метаинформации уже не поворачивается при отображении, а остается такая как есть. Тоесть с точки зрения пользователя — непредсказуемо поворачиваются.
>>> # ...
>>> im.save('saved.jpg')
>>> im_saved = Image.open('saved.jpg')
>>> exif = im_saved._getexif()
>>> exif is None
True
>>>
Поворот перед сохранением
Поскольку PIL
стирает метаинформацию при сохранении изображения, то будем поворачивать изображение при наличии тега ориентации самостоятельно.
def orient(image_path):
""" Check are this img oriented """
img = Image.open(image_path)
# check is has exif data
if not hasattr(img, '_getexif') or img._getexif() is None:
return
# if has information - try rotate img
orientation = img._getexif().get(0x112)
rotate_values = {3: 180, 6: 270, 8: 90}
if orientation in rotate_values:
img = img.rotate(rotate_values[orientation])
img.save(image_path, quality=100)
Данная функция проверяет наличие информации о повороте изображения, и если она присутствует, поворачивает картинку на соответствующий угол. Сохраняя изображение метаинформация стирается, и поэтому, если даже exif orientation
тег был не фотоаппаратный (3, 6, 8), а отраженный (2, 4, 5, 7). То картинка будет такой как и есть в действительности, и в случае необходимости, модераторы смогут её повернуть как им нужно.
Вот такой простой функцией решилась эта коварная ошибка.
Наглядный пример
Для большей наглядности, приведем пример неправильной обрезки изображения из-за кеширования, а так же различного поведения изображения с exif
ориентацией и без. В качестве исходного изображения будем использовать знаменитую Лену, но для более заметных изменений, не её канонический вариант 512x512, а прямоугольное изображение. Для работы с exif
информацией воспользуемся консольной утилитой exiftool.
Ошибка кеширования
Возьмем портретно ориентированное изображение, и попробуем его дважды обрезать на одном и том же превью изображении.
Модератор обрезает изображение, затем после обновления страницы повторно загружается тоже-самое превью. Модератор обрезает изображение второй раз, думая при этом, что изменения по какой либо причине не сохранились.
Но, на самом деле, изображение в первый раз обрезалось корректно.
Получив повторные координаты для обрезки изображения, происходит следующее.
И так далее, при каждой последующей обрезки исходное изображение будет продолжать кромсаться. Поэтому случайный GET
параметр позволяет бороться с кешированием и загружать необходимое превью для правильной обработки изображения.
Exif ориентация
Создадим исходное изображение, с ландшафтным соотношение сторон, без заданного тега ориентации.
Отобразим метаинформацию в данном изображении.
$ exiftool lenna_default.jpg
ExifTool Version Number : 9.46
File Name : lenna_default.jpg
Directory : .
File Size : 79 kB
File Modification Date/Time : 2015:09:06 10:40:30+03:00
File Access Date/Time : 2015:09:06 10:49:37+03:00
File Inode Change Date/Time : 2015:09:06 10:49:12+03:00
File Permissions : rw-rw-r--
File Type : JPEG
MIME Type : image/jpeg
JFIF Version : 1.01
Exif Byte Order : Little-endian (Intel, II)
Orientation : Horizontal (normal)
X Resolution : 72
Y Resolution : 72
Resolution Unit : inches
Exif Version : 0210
Flashpix Version : 0100
Color Space : Uncalibrated
Exif Image Width : 463
Exif Image Height : 584
Image Width : 584
Image Height : 463
Encoding Process : Baseline DCT, Huffman coding
Bits Per Sample : 8
Color Components : 3
Y Cb Cr Sub Sampling : YCbCr4:2:0 (2 2)
Image Size : 584x463
Скопируем изображение и установим тег ориентации 6.
$ cp lenna_default.jpg lenna_exif_6.jpg
$ exiftool -n -Orientation=6 lenna_exif_6.jpg
1 image files updated
Чтобы увидеть поворот откройте изображение в новой вкладке.
Теперь посмотрим какая метаинформация содержится в этом изображении.
$ exiftool lenna_exif_6.jpg
ExifTool Version Number : 9.46
File Name : lenna_exif_6.jpg
Directory : .
File Size : 79 kB
File Modification Date/Time : 2015:09:06 10:41:52+03:00
File Access Date/Time : 2015:09:06 10:49:25+03:00
File Inode Change Date/Time : 2015:09:06 10:49:18+03:00
File Permissions : rw-rw-r--
File Type : JPEG
MIME Type : image/jpeg
JFIF Version : 1.01
Exif Byte Order : Little-endian (Intel, II)
Orientation : Rotate 90 CW
X Resolution : 72
Y Resolution : 72
Resolution Unit : inches
Exif Version : 0210
Flashpix Version : 0100
Color Space : Uncalibrated
Exif Image Width : 463
Exif Image Height : 584
Image Width : 584
Image Height : 463
Encoding Process : Baseline DCT, Huffman coding
Bits Per Sample : 8
Color Components : 3
Y Cb Cr Sub Sampling : YCbCr4:2:0 (2 2)
Image Size : 584x463
Как видно, в тег ориентации установлено значение Rotate 90 CW
.
Загрузив данные изображения на сервис - можно увидеть различное поведение:
Изображение без тега ориентации не поворачивается и отображается как есть, с ландшафтным соотношением сторон.
Изображение с тегом ориентации при отображении поворачивается в портретное соотношение сторон.
Второе изображение выглядит как нужно и модератор без сомнений обрезает изображение.
Обрезая изображение как показано на рисунке сверху, модератор ожидает получить следующий результат.
Однако, поскольку координаты заданы для портретной ориентации, а само изображение имеет размеры альбомной ориентации, результат получается неожиданным. Картинка поворачивается, и возникает черная полоса снизу изображения.
После использования предварительного поворота изображения, и удаления метаинформации, данное неожиданное поведение исчезает, изображение отображается таким какое оно есть на самом деле. Загружая картинку с тегом ориентации, при сохранении на сервере она сразу сохраняется поворачивается.
Тоесть в случае, если модератор обрезает портретное изображение.
Он получает, правильный портретный результат.