Статьи

Метаданные изображения и ориентация

ALittleMoronBackend-разработка0 просмотров
Разработчик проверяет одно фото с пейзажем, которое по-разному повернуто на экранах и в документе.

Сам недавно это узнал, но у изображения в метаданных есть ориентация. Конкретно это EXIF-тег Orientation, он же 274 или 0x0112. Он не обязательно меняет сами пиксели картинки. Чаще он говорит просмотрщику: вот файл как он лежит на диске, а показывать его нужно вот так — повернуть, отразить или сделать и то, и другое.

Именно этот тег и становится камнем преткновения. Проблема начинается, когда разные части пайплайна по-разному относятся к Orientation: браузер, генератор html, генератор docx, офисный редактор, превьюшка в системе. Где-то тег учитывается, где-то игнорируется, где-то картинка уже была повернута, но тег остался. В итоге фото может выглядеть нормально в одном месте и внезапно перевернуться в другом.

Вот пример файла (ссылка на оригинальные файлы тут):

image

Странно? Очень. И такая вещь может происходить повсеместно. Правда это довольно редкий сценарий, но много систем просто не готовы к такому. Наша система тоже была не готова. Выстрелило оно при формировании документа для подписания кандидатом.

Решить эту проблему можно было бы при загрузке файла: один раз применить ориентацию к пикселям и убрать или сбросить EXIF-тег. Но на тот момент доступа к загрузке файлов у меня не было, поэтому пришлось менять ориентацию файла прямо перед вставкой в документ.

Что значат значения Orientation

У Orientation есть восемь значений:

Значение Что нужно сделать для нормального отображения
1 Ничего, картинка уже в нормальном положении
2 Отразить слева направо
3 Повернуть на 180 градусов
4 Отразить сверху вниз
5 Отразить по главной диагонали
6 Повернуть на 270 градусов
7 Отразить по побочной диагонали
8 Повернуть на 90 градусов

Тут важный подвох: 2, 4, 5 и 7 — это не просто поворот, а отражение. Поэтому решение вида "если не так повернулось, повернем на 90 градусов" быстро ломается на части примеров.

Современный вариант

В Pillow сейчас есть готовая функция ImageOps.exif_transpose. Она читает Orientation, применяет нужный transpose к пикселям и убирает тег ориентации, чтобы следующая система не повернула картинку повторно.

from PIL import Image, ImageOps


def normalize_image_orientation(image: Image.Image) -> Image.Image:
    return ImageOps.exif_transpose(image)

Если нужно сохранить результат в файл, дальше уже сохраняем именно эту нормализованную картинку:

from pathlib import Path

from PIL import Image, ImageOps


def normalize_image_file(source: Path, destination: Path) -> None:
    with Image.open(source) as image:
        normalized_image = ImageOps.exif_transpose(image)
        normalized_image.save(destination)

После этого у изображения уже должны быть нормальные пиксели без инструкции "а теперь поверни меня еще раз".

Ручной вариант

Старый код всё равно полезно оставить, потому что он хорошо показывает, что на самом деле происходит внутри exif_transpose.

from PIL import Image


def rotate_image_by_orientation(_file: Image.Image) -> Image.Image:
    orientation: int = _file.getexif().get(274, 1)
    match orientation:
        case 2:
            _file = _file.transpose(
                Image.Transpose.FLIP_LEFT_RIGHT,
            )
        case 3:
            _file = _file.transpose(
                Image.Transpose.ROTATE_180,
            )
        case 4:
            _file = _file.transpose(
                Image.Transpose.ROTATE_180,
            ).transpose(
                Image.Transpose.FLIP_LEFT_RIGHT,
            )
        case 5:
            _file = _file.transpose(
                Image.Transpose.ROTATE_270,
            ).transpose(
                Image.Transpose.FLIP_LEFT_RIGHT,
            )
        case 6:
            _file = _file.transpose(
                Image.Transpose.ROTATE_270,
            )
        case 7:
            _file = _file.transpose(
                Image.Transpose.ROTATE_90,
            ).transpose(
                Image.Transpose.FLIP_LEFT_RIGHT,
            )
        case 8:
            _file = _file.transpose(
                Image.Transpose.ROTATE_90,
            )
        case _:
            pass
    data = list(_file.getdata())
    image_without_exif = Image.new(_file.mode, _file.size)
    image_without_exif.putdata(data)
    _file = image_without_exif
    return _file

Этот match написан корректно для Pillow. Там есть места, которые выглядят неочевидно, но они эквивалентны стандартным transpose-операциям:

  • 4: ROTATE_180 + FLIP_LEFT_RIGHT получается тем же самым, что FLIP_TOP_BOTTOM;
  • 5: ROTATE_270 + FLIP_LEFT_RIGHT получается тем же самым, что TRANSPOSE;
  • 7: ROTATE_90 + FLIP_LEFT_RIGHT получается тем же самым, что TRANSVERSE.

Но сейчас я бы не стал держать такую таблицу руками, если нет особой причины. Готовый ImageOps.exif_transpose проще прочитать и сложнее случайно сломать.

Что с удалением EXIF

В конце старого кода есть вот эта часть:

data = list(_file.getdata())
image_without_exif = Image.new(_file.mode, _file.size)
image_without_exif.putdata(data)

Она пересоздает картинку только из пикселей и тем самым грубо выбрасывает все метаданные. Для моей задачи это было нормально: документу для подписания не нужны были EXIF-данные исходной фотографии, а повторного поворота быть не должно.

Но это не универсально безопасное действие. Вместе с Orientation можно потерять дату съемки, GPS, DPI, цветовой профиль, данные камеры и другие поля. Если эти данные важны, лучше не вычищать EXIF целиком, а применять ориентацию и отдельно удалять или сбрасывать только Orientation.

Еще одна мелочь, о которой легко забыть: после нормализации нужно работать уже с новыми размерами изображения. Для значений 5, 6, 7 и 8 ширина и высота меняются местами.