Ходіння по муках із Facebook360 panorama

Завантажуючи панорами в Google Photo вони інколи "оживали" і вмикався вбудований переглядач для кругових панорам. Це було доволі класно, але, як я не старався, виявити якусь залежність між налаштуваннями склейки панорам і активацією переглядача знайти мені не вдавалося.



Потім в однієї людини на FB побачив "живу" панораму в якості обкладинки профіля - це підштовхнуло мене погуглити як же правильно робити панорами, щоб вони "оживали". Знайшов готову програму, яка усе робить сама: Exif Fixer. Завантажив для Linux x64 - усе працює, показує теги, які будуть інтегровані в Exif, але результат не зберігається. Програма поширюється бінарником, тому покопатися в ній не вийде.

Ок, в Linux є чудова утиліта ExifTool, пробуємо інтегрувати теги за її допомогою, тестове завантаження панорами в FB - усе працює. Але це граблі конкретні, запускати програму, копіювати з неї теги й в консолі інтегрувати їх.

Та яжпрограміст: зроблю свій велосипед :)
Треба розібратися як обчислювати необхідні значення тегів. Після кількох годин читання мануалів на facebook360.fb.com та developers.facebook.com більш менш розібрався. Тримайте вільний виклад тих постулатів:
  1. Представлення панорами завжди має вигляд прямокутного зображення зі співвідношенням 2:1 (360° * 180°), що при згортці дає нам повну сферичну поверхню.
  2. Наша панорама може займати або усе поле повної панорами 360° * 180°, або ж якусь частину (наприклад, 180° * 86°);
  3. В Exif обов'язково прописати такі параметри: ProjectionType, CroppedAreaImageWidthPixels, CroppedAreaImageHeightPixels, FullPanoWidthPixels, FullPanoHeightPixels.
  4. Для панорам, які не займають усе поле сфери бажано ще додатково прописати параметри: CroppedAreaLeftPixelsCroppedAreaTopPixels.
Спробуємо тепер розібратися що куди й до чого. В цьому нам допоможе наступне зображення (параметри обізвані по перших літерах слів в їхній назві):

Нічого не зрозуміло? Зараз поясню.
На вході ми маємо нашу панораму (внутрішній прямокутник). Її фізичні розміри CAI_WP * CAI_HP отримуємо з неї самої (ширина та висота в пікселях). FP_WP та FP_HP - розміри повного поля сферичної панорами (FP_HP завжди дорівнює FP_WP / 2), а CA_LP та CA_TP - відступи від країв повного поля (зазвичай панораму розміщують по центру повного поля).

Єдиний параметр, який не показано вище - ProjectionType. Він може приймати 3 значення: equirectangular, cylindrical, cubestrip. equirectangular використовується, коли в нас на вході є повна сферична панорама, тоді FP_WP = CAI_WP, FP_HP = CAI_HP, CA_LP = 0, CA_HP = 0. cylindrical використовується для панорам, які не займають усе сферичне поле зору. Всі параметри зображення в такому разі нам необхідно порахувати. І, врешті-решт, cubestrip - це така "вундервафля", що використовує проекції сфери на внутрішню поверхню куба. Страшна і не зрозуміла штука, яку можна використовувати, якщо ми маємо 6 камер для створення повної сферичної панорами, але то вже тема для абсолютно іншої статті.

Отже, реалізуємо це все діло в коді.
На вході нам треба мати саме зображення та один із кутових розмірів (найпростіше запам'ятати, огляд панорами по горизонталі):
filename = '~/panorama.jpg'
width_degree = 120
Далі, отримуємо розміри зображення:
from PIL import Image
im = Image.open(filename)
width, height = im.size
Вирахуємо кут огляду по вертикалі:
height_degree = int(width_degree * height / width)
Визначимося з типом проекції:
if width_degree == 360 and height_degree == 180:
    projection = "equirectangular"else:
    projection = "cylindrical"
Вирахуємо поле огляду повної сферичної панорами:
full_width = int(width * 360 / width_degree)
full_height = int(full_width / 2)
І, відступи від країв повного поля:
offset_left = int((full_width - width) / 2)
offset_up = int((full_height - height) / 2)
Усі дані отримані, тепер їх якось треба записати в Exif зображення. Є кілька бібліотек для роботи із Exif-ами, але такої, яка могла б записувати в xmp нестандартні теги я не знайшов (якщо хто знає, підкажіть). Тому, довелося викручуватися із ExifTool:
command = f"exiftool \
    -ProjectionType={projection} \
    -CroppedAreaImageWidthPixels={width} \
    -CroppedAreaImageHeightPixels={height} \
    -FullPanoWidthPixels={full_width} \
    -FullPanoHeightPixels={full_height} \
    -CroppedAreaLeftPixels={offset_left} \
    -CroppedAreaTopPixels={offset_up}"
process = subprocess.Popen(command.split(' ') + [filename],
                           stdout=subprocess.PIPE)
output, error = process.communicate()

Повний код можна знайти на GitLab.

Коментарі