Fork me on GitHub

Вот это поворот… Sep 06, 2015

Однажды столкнулся с магическим багом, причину возникновения которого, не удавалось отыскать почти на протяжении месяца. Да что там причину, его даже воспроизвести никак не удавалось. Заказчик постоянно жаловался что изображения обрезаются неправильно, да при том ещё непредсказуемо поворачиваются…

Неуловимая ошибка

Суть проекта заключалась в том, что пользователи выкладывают фотографии, сопровождая их коротенькими историями, далее модераторы проверяют контент на соответствие тематике ресурса, обрезают фотографии по необходимому соотношению сторон и публикуют эти истории.

Буквально с первых же дней выхода проекта в продакшн, начали поступать жалобы от модераторов. Жаловались на то, что фотографии обрезаются не так как это ожидается, иногда появляются черные полоски, иногда фотографии внезапно поворачиваются, иногда вообще не происходит каких либо изменений после обрезки.

Для обрезки изображений использовалась библиотека 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 варианта тега ориентации:

exif orient

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.

Ошибка кеширования

Возьмем портретно ориентированное изображение, и попробуем его дважды обрезать на одном и том же превью изображении.

lenna

Модератор обрезает изображение, затем после обновления страницы повторно загружается тоже-самое превью. Модератор обрезает изображение второй раз, думая при этом, что изменения по какой либо причине не сохранились.

lenna

Но, на самом деле, изображение в первый раз обрезалось корректно.

lenna

Получив повторные координаты для обрезки изображения, происходит следующее.

lenna

И так далее, при каждой последующей обрезки исходное изображение будет продолжать кромсаться. Поэтому случайный GET параметр позволяет бороться с кешированием и загружать необходимое превью для правильной обработки изображения.

Exif ориентация

Создадим исходное изображение, с ландшафтным соотношение сторон, без заданного тега ориентации.

lenna default

Отобразим метаинформацию в данном изображении.

$ 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

lenna exif rotate Чтобы увидеть поворот откройте изображение в новой вкладке.

Теперь посмотрим какая метаинформация содержится в этом изображении.

$ 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.

Загрузив данные изображения на сервис - можно увидеть различное поведение:

Изображение без тега ориентации не поворачивается и отображается как есть, с ландшафтным соотношением сторон.

lenna on service default

Изображение с тегом ориентации при отображении поворачивается в портретное соотношение сторон.

lenna on service exif

Второе изображение выглядит как нужно и модератор без сомнений обрезает изображение.

lenna crop

Обрезая изображение как показано на рисунке сверху, модератор ожидает получить следующий результат.

lenna correct crop

Однако, поскольку координаты заданы для портретной ориентации, а само изображение имеет размеры альбомной ориентации, результат получается неожиданным. Картинка поворачивается, и возникает черная полоса снизу изображения.

lenna wrong crop

После использования предварительного поворота изображения, и удаления метаинформации, данное неожиданное поведение исчезает, изображение отображается таким какое оно есть на самом деле. Загружая картинку с тегом ориентации, при сохранении на сервере она сразу сохраняется поворачивается.

Тоесть в случае, если модератор обрезает портретное изображение.

lenna on service exif

lenna crop

Он получает, правильный портретный результат.

lenna correct crop