Собралось много фотографий на различных носителях, да еще в полном беспорядке. Где, когда было отснято, и как это все отсортировать и привести в порядок? Объемы большие, работы много. Сортировать все это вручную, так себе вариант. Использовать какие-то органайзеры и прочий софт желания не было. Тем более что простой просмотрщик уже был установлен. Но использовать его для сортировки занятие не только утомительное, но еще и затратное по времени и силам. Побродив немного по всемирной паутине в поисках решения, как это все сделать просто, да еще и в фоновом режиме и не найдя подходящего, для себя по крайней мере, решения, написал простенький скрипт на shell-е. Почему shell? Да все просто, его более чем достаточно для этой задачи. Плюс внешние утилиты командной строки, используемые в скрипте, в любом Linux идут по дефолту. Но это все предыстория, а нам пора переходить  к скрипту.

Что умеет скрипт

Представьте что у нас хранятся сотни тонн всевозможных снимков в совершенно разных местах, на разных носителях. Где-то они были разобраны по годам и месяцам, а где-то просто “свалены” в кучу и оставлены до лучших времен.

Мы запускаем программу указываем ей начальную точку, откуда начинать, и идем заниматься своими делами. Скрипт сканирует все директории какие встретит на своем пути, включая и вложенные, в поисках фотографий. После завершения работы программы получаем фотоальбом с отсортированными снимками по годам и месяцам в каждом году, т.е. в виде: год / месяц / снимки. Да еще и без дубликатов фотографий. А сами снимки будут переименованы из непонятных типа IMG_654372984.jpg или AB54645456.jpg во вполне читабельные и понятные имена вида YYYYMMDD_hhmmss.jpg. При этом не пострадает ни один оригинал фотографии.

сам скрипт import-photo
#! /usr/bin/env bash

# -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=
# Импорт фотографий в фотоальбом
# author: VlaSard
# github: https://github.com/VlaSard
# date: 2022.09.05
# photo-import
#
# DESCRIPTION:
#   Импортирует фотографии в фотоальбом и сортирует по директориям [YYYY / MM].
#   Переименовывает фотографии в формат YYYYMMDD_hhmmss.jpg.
#   НЕ УДАЛЯЕТ оригиналы файлов.
#   НЕ УВЕЛИЧИВАЕТ разрешение.
#
#   !!! convert - РАБОТАЕТ БЕЗ ПРОВЕРОК НА НАЛИЧИЕ ДУБЛИКАТОВ !!!
#
# REVIEWS:
#   exiv2
#   convert
#
# USAGE:
#   photo-import [dir_name]
#   dir_name - необязательный параметр, откуда будем импортировать фотографии
#
# -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=

# -=-=-=-=-=-=-= VARIABLES =-=-=-=-=-=-=-
# если каталог с фотографиями не указан, используется текущий каталог
SourceDir=${1:-${PWD}}

# каталог назначения
DestDir="$HOME/Photo_album"

# настройка импорта фотографий:
# import - импорт без изменения фотографий;
# convert - импорт с изменением фотографий;
ImportPhoto=import

# степень сжатия фотографии
Quality=80

# максимальный размер фотографии
Resize=1920x1080

# переменные colors
NORMAL='\033[0m'
GREEN='\033[32m'
CYAN='\033[36m'
# RED='\033[31m'

# -=-=-=-=-=-=-= MESSAGES =-=-=-=-=-=-=-=-
msg_FileExists=("Файл" "уже есть в альбоме! ${CYAN}Пропускаем${NORMAL}.")
msg_FileImport=("Импортируем фотографию" "в альбом.")

# -=-=-=-=-=-=-= FUNCTIONS =-=-=-=-=-=-=-

# -== вывод сообщений скрипта ==-
# параметры:
#   ${message[@]} - сообщение в виде массива
#   ${file_name} - имя файла
msgPrint() {
    printf "%b\n" "${1} ${GREEN}${3}${NORMAL} ${2}"
}

# -== проверка наличия фотографии в альбоме ==-
# параметры:
#   $1 - объект источник
#   $2 - объект назначение
# возвращает: $FileExists
#   0 - объекта $2 нет
#   1 - объект $2 есть и он идентичен объекту $1
#   2 - объект $2 есть и он отличается от объекта $1
file_exists() {
    FileExists=0

    if [ -f "${2}" ]; then

        # если файлы одинаковые, пропускаем
        if cmp -s "${1}" "${2}"; then
            msgPrint "${msg_FileExists[@]}" "${1}"
            FileExists=1
            return
        fi

        FileExists=2
        return

    fi
}

# -== импорт фотографий без изменений ==-
# переменные:
#   $1 - находится полное имя фотографии в альбоме
#   FilePhoto - объект источник
#   destPhoto - объект назначения
#   baseName - имя файла без расширения
#   count - счетчик дубликатов фотографии
import() {
    destPhoto="${1}"
    local baseName="${destPhoto%.*}"
    local COUNT=1

    # проверить наличие фотографии в альбоме
    file_exists "${FilePhoto}" "${destPhoto}"

    # если фотография в альбоме есть - пропускаем
    if [ "${FileExists}" = 1 ]; then
        return 1

    # если фотография в альбоме есть, но отличается от исходной
    elif [ "${FileExists}" = 2 ]; then

        # проверяем все похожие фотографии
        while [ "${FileExists}" != 0 ]; do

            # добавили к имени счетчик и ушли на проверку
            NewName="${baseName}-${COUNT}.jpg"
            COUNT=$((COUNT + 1))
            file_exists "${FilePhoto}" "${NewName}"

            if [ "${FileExists}" = 1 ]; then
                return 1
            fi

        done

        destPhoto="${NewName}"

    fi

    # если фотографии в альбоме нет - импортируем
    msgPrint "${msg_FileImport[@]}" "${destPhoto}"
    cp -up "${FilePhoto}" "${destPhoto}"
}

# импорт с изменением фотографий
# переменные:
#   $1 - находится полное имя фотографии в альбоме
#   FilePhoto - объект источник
#   destPhoto - объект назначения
convert() {
    destPhoto="${1}"

    # если фотография есть в альбоме - пропускаем
    # if ! file_exists "${FilePhoto}" "${destPhoto}"; then
    #     return 1
    # fi

    # изменить качество и размер фотографии, переименовывать и копировать в альбом
    msgPrint "${msg_FileImport[@]}" "${FilePhoto}"
    convert -quality "${Quality}" -resize "${Resize}" "${FilePhoto}" "${destPhoto}"

}

# -=-=-=-=-=-=-=-= MAIN  =-=-=-=-=-=-=-=-

# перебираем все jpg-файлы в указанной директории и всех поддиректориях
find "${SourceDir}" -iname "*.jpg" | sort |
    while read -r FilePhoto; do

        for PhotoDate in "Exif.Photo.DateTimeOriginal" "Exif.Image.DateTime"; do

            # читаем дату из EXIF
            PhotoDate=$(exiv2 -g "${PhotoDate}" -Pv "${FilePhoto}")

            # если прочитали дату снимка, прекращаем поиск даты
            [ -n "${PhotoDate}" ] && break

        done

        # если не нашли в EXIF ищем в названии файла
        if [ -z "${PhotoDate}" ]; then

            # ищем дату в названии файла и формируем в виде YYYY:MM:DD HH:MM:SS для корректного добавления в EXIF
            PhotoDate=$(
                basename "${FilePhoto}" ".jpg" |
                    perl -e 'if
                    (<> =~ /(\d{4})[-_:\ ]?(\d{2})[-_:\ ]?(\d{2})[-_:\ ]?(\d{2})[-_:\ ]?(\d{2})[-_:\ ]?(\d{2})/s)
                    { print "$1:$2:$3 $4:$5:$6" }'
            )

            if [ -n "${PhotoDate}" ]; then

                # если нашли добавляем дату из названия файла в EXIF
                exiv2 -M "add Exif.Image.DateTime Ascii ${PhotoDate}" "${FilePhoto}"

            else

                # если даты в названии не нашли, берем дату создания (изменения) файла
                PhotoDate=$(date +"%Y:%m:%d %T" -r "${FilePhoto}")

            fi

        fi

        # формируем массив с переменными для фотоальбома
        # AlbumParam[0] - адрес фотоальбома
        # AlbumParam[1] - новое имя файла фотографии
        # AlbumParam[2] - дата создания в соответствии с EXIF
        IFS=" " read -r -a AlbumParam <<<"$(echo "$PhotoDate" |
            perl -e 'if
            (<> =~ /(\d{4})[-_:\ ]?(\d{2})[-_:\ ]?(\d{2})[-_:\ ]?(\d{2})[-_:\ ]?(\d{2})[-_:\ ]?(\d{2})/s)
            { print "$1/$2 $1$2$3_$4$5$6.jpg $1$2$3$4$5.$6" }')"

        # создать структуру фотоальбома (YYYY / MM)
        [ -d "${DestDir}/${AlbumParam[0]}" ] || mkdir -p "${DestDir}/${AlbumParam[0]}"

        # если импортировали фотографию
        if ${ImportPhoto} "${DestDir}/${AlbumParam[0]}/${AlbumParam[1]}"; then

            # установить дату создания в соответствии с датой в EXIF
            touch -t "${AlbumParam[2]}" "${destPhoto}"

        fi

    done

Под капотом

Подготовительные операции

Как уже упоминалось ранее при запуске мы указываем скрипту откуда начинать импортировать фотографии, начальную точку. Если этого не сделать, что допустимо, он сам определит в какой директории он был запущен и начнет оттуда. Директория фотоальбома задается только в теле скрипта, вряд ли она так часто меняется, чтобы передавать ее через командную строку. При желании нужно изменить всего пару строк. За все это отвечают строки приведенные ниже. В SourceDir присваивается или директория переданная через командную строку или директория в которой был запущен скрипт.

SourceDir=${1:-${PWD}}
DstDir="$HOME/Phot_album"

А дальше начинаем читать файл за файлом и раскладывать по директориям в альбоме, попутно создавая их если не было.

Ищем и читаем только jpg файлы. Поскольку это основной формат для хранения фотографий. Строки отвечающие за поиск и чтение файлов приведены ниже:

find "${SourceDir}" -iname "*.jpg" | sort |
	while read -r FilePhoto; do

	...

	done

Прочитав файл, пытаемся определить дату снимка из параметров EXIF. Циклом перебираем два тега и пытаемся прочитать из них информацию. Нашли, отлично. Пропускаем оставшиеся варианты поиска даты, проверив наличие параметра в переменной PhotoDate. Если их там не нашлось ищем в названии файла. При этом используется регулярка Perl т.к. она лучше документирована, позволяет сразу сформировать нужный результат, да и занимает всего одну строчку кода. В отличие от shell-овского варианта где пришлось бы использовать grep для поиска и sed для формирования результата. Если же в названии даты не нашлось возьмем дату создания (изменения) файла. Код выполняющий все описанные действия:

чтение даты
for PhotoDate in "Exif.Photo.DateTimeOriginal" "Exif.Image.DateTime"; do

	# читаем дату из EXIF
	PhotoDate=$(exiv2 -g "${PhotoDate}" -Pv "${FilePhoto}")

	# если прочитали дату снимка, прекращаем поиск даты
	[ -n "${PhotoDate}" ] && break

done

# если не нашли в EXIF ищем в названии файла
if [ -z "${PhotoDate}" ]; then

	# ищем дату в названии файла и формируем в виде YYYY:MM:DD HH:MM:SS для корректного добавления в EXIF
	PhotoDate=$(
		basename "${FilePhoto}" ".jpg" |
			perl -e 'if 
			(<> =~ /(\d{4})[-_:\ ]?(\d{2})[-_:\ ]?(\d{2})[-_:\ ]?(\d{2})[-_:\ ]?(\d{2})[-_:\ ]?(\d{2})/s)
			{ print "$1:$2:$3 $4:$5:$6" }'
	)

	if [ -n "${PhotoDate}" ]; then

		# если нашли добавляем дату из названия файла в EXIF
		exiv2 -M "add Exif.Image.DateTime Ascii ${PhotoDate}" "${FilePhoto}"

	else

		# если даты в названии не нашли, берем дату создания (изменения) файла
		PhotoDate=$(date +"%Y:%m:%d %T" -r "${FilePhoto}")

	fi

fi

Отлично. Дату нашли. Теперь формируем необходимые переменные для создания структуры фотоальбома. Этим также займется perl-однострочник. Сформируем сразу все переменные, у нас их три, и сложим в массив. Bash умеет неплохо работать с массивами.

IFS=" " read -r -a AlbumParam <<<"$(echo "$PhotoDate" | perl -e 'if (<> =~ /(\d{4})[-_:\ ]?(\d{2})[-_:\ ]?(\d{2})[-_:\ ]?(\d{2})[-_:\ ]?(\d{2})[-_:\ ]?(\d{2})/s) { print "$1/$2 $1$2$3_$4$5$6.jpg $1$2$3$4$5.$6" }')"

В shell-е пришлось бы написать подобную конструкцию в четыре строки кода. Разобрать дату на составляющие. А уже потом в каждой строке формировать три переменные. Здесь же одна строка кода.

В строках:

if ${ImportPhoto} "${DestDir}/${AlbumParam[0]}/${AlbumParam[1]}"; then
	touch -t "${AlbumParam[2]}" "${destPhoto}"
fi

запускаем процедуру импорта фотографии и установки даты создания файла в соответствии с датой в EXIF. Если ничего не импортировали, то ничего и не меняем.

Импорт снимка

За импорт фотографии отвечает функция import, код функции ниже:

функция import()
import() {
    destPhoto="${1}"
    local baseName="${destPhoto%.*}"
    local COUNT=1

    # проверить наличие фотографии в альбоме
    file_exists "${FilePhoto}" "${destPhoto}"

    # если фотография в альбоме есть - пропускаем
    if [ "${FileExists}" = 1 ]; then
        return 1

    # если фотография в альбоме есть, но отличается от исходной
    elif [ "${FileExists}" = 2 ]; then

        # проверяем все похожие фотографии
        while [ "${FileExists}" != 0 ]; do

            # добавили к имени счетчик и ушли на проверку
            NewName="${baseName}-${COUNT}.jpg"
            COUNT=$((COUNT + 1))
            file_exists "${FilePhoto}" "${NewName}"

            if [ "${FileExists}" = 1 ]; then
                return 1
            fi

        done

        destPhoto="${NewName}"

    fi

    # если фотографии в альбоме нет - импортируем
    msgPrint "${msg_FileImport[@]}" "${destPhoto}"
    cp -up "${FilePhoto}" "${destPhoto}"
}

Здесь все достаточно просто. В функцию передается только имя файла назначения. Прежде всего нужно проверить существует или нет снимок в альбоме, поручим это самостоятельной функции file_exists. Функция будет возвращать три значения проверки:

  • 0 - файла нет;

  • 1 - файл есть и он идентичен файлу источнику

  • 2 - файл есть но отличается от источника

С первыми двумя вариантами (0, 1) все просто или пропускаем или импортируем. В случае возврата в результатах проверки 2, просто перебираем имена, добавляя индекс к имени файла и отправляя на повторную проверку. Как только определим, что снимка с таким именем в альбоме нет производим импорт фотографии.

функция file_exists()
file_exists() {
    FileExists=0

    if [ -f "${2}" ]; then

        # если файлы одинаковые, пропускаем
        if cmp -s "${1}" "${2}"; then
            msgPrint "${msg_FileExists[@]}" "${1}"
            FileExists=1
            return
        fi

        FileExists=2
        return

    fi
}

Проверили наличие файла и если файл существует сравнили по содержимому файл источник и файл назначения и вернули соответствующее значение.

Полный текст скрипта приведен выше. Также он доступен на GitHub по ссылке.

Эпилог

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

Почему скрипт на bash, а не python к примеру? Хотя бы потому, что пришлось бы установить пакет для работы с тегами фотографий. В bash он присутствует по умолчанию. Да и задача не настолько тяжелая чтобы решать на других языках.

Единственное, что не прикрутил к нему конвертер снимков, для уменьшения размера файла. Да вряд ли такой функционал будет востребован. Но заготовку функции в скрипт вставил. А поскольку у него только одна функция импорта работает то и парсера командной строки тоже не стал добавлять. Также как нет и справки по работе с программой. Все настройки внутри скрипта в самом начале ровно как и небольшой туториал по нему.

Комментарии (13)


  1. Javian
    16.09.2022 15:50

    офф Было бы еще полезнее извлекать из EXIF координаты места съемки и раскидывать "по адресам" или генерировать kmz для Google Earth c превьюшками.


  1. bdaring
    16.09.2022 18:00
    +2

    Дорогой тезка, преогромнейшее тебе спасибо! Именно то, что я давно искал среди монструозных комбайнов, но ленился сделать сам.
    Единственно что могу предложить, это заменить cp на ln, чтобы места лишнего не занимать.


    1. VlaSard Автор
      17.09.2022 10:12

      Замена cp на ln будет работать только если фотографии находятся на несъемном носителе и их нужно только отсортировать. Но если подключается, к примеру, фотоаппарат или телефон и будут созданы только ссылки на снимки, то при отключении внешнего носителя самих фотографий мы уже не увидим. Да и сцениарий задумывался для переноса фотографий с внешних носителей и сортировки по времени съемки. А для уменьшения размера можно использовать тот же convert, который позволит изменить размер снимка и степень сжатия jpg.


  1. papilaz
    17.09.2022 00:17

    Вот спасибо за спасение моего времени. Крутой скрипт. И хранить я думаю можно уже в webp.


  1. engine9
    17.09.2022 09:32

    О, я тоже поделюсь bash-скриптом, который нашел много лет назад на каком-то форуме. Суть его работы несколько иная: он сканирует директорию, находит файлы с EXIF и переносит их в директории вида 2022.05.25. Он требует предварительной установки exiftool

    #!/bin/bash
    echo "Запуск сортировки фотографий из директории /Photo/Unsorted" 
    cd "/Photo/Assorted" && exiftool "-Directory<DateTimeOriginal" -r -d "%Y.%m.%d" "/Photo/Unsorted"

    Я сам не программист, понимаю как эта магия работает в общих чертах, вначале во второй строке есть напоминалка с путём откуда скрипт будет забирать фотографии. Этот путь нужно прописать в конце скрипта (заменить /Photo/Unsorted на путь к директории на вашей машине, которая будет служить "свалкой" фотографий), а директория из которой запускается скрипт (/Photo/Assorted) это место куда будут складываться фотографии в папки.

    Я проверял его работу с *.NEF, *.CR2, *.ORF, *.jpg файлами, работает корректно. Так же он выведет ошибку в консоль, если обнаружит файлы с одинаковыми именами или некорректными полями EXIF.

    Для меня такой пайплайн более предпочтителен, т.к. мой стиль работы это отснять несколько флешек, потом свалить всю эту кучу на комп в одну директорию, удалить лишнее и потом отсортировать скриптом.


    1. VlaSard Автор
      17.09.2022 10:20
      +1

      У каждого задачи отличаются. По поводу exiftool согласен, он требует установки. Но в своем сценарии использовал exiv2, который идет уже предустановленный. Да и обрабатываю я меньше снимков. Относительно много бывает после командировок, но с таким объемом сценарий справляется.


      1. engine9
        17.09.2022 10:54
        +1

        А вот интересно, возможно ли сделать такую тулзу, чтобы она брала данные их GPX трека (который записал старенький GPS трекер garmin) и вшивала данные геолокации в фотографии.

        Иногда я выбираюсь поснимать в отдалённые деревеньки и беру с собою трекер, чтобы видеть как ходил.


        При условии, что у нас часы на фотоаппарате настроены точно. Насколько сложно будет такое сделать?


        1. VlaSard Автор
          17.09.2022 11:07
          +1

          Зашить данные геолокации в фотографию можно через exiftool командой.

          exiftool -geotag ~/Documents/Travel/.../some_track.gpx *.tif

          Если выполнять синхронизацию по времени снимка и времени из трека gpx думаю что такое получится.


          1. engine9
            17.09.2022 18:05

            Круто, уже сделали умные люди, спасибо!


        1. shoorick
          17.09.2022 16:23
          +1

          Да хоть бы и неточно — если знать разницу между временем фотоаппарата и GPS-трекера, то можно указать смещение в уже упомянутом exiftool — оно там задаётся в секундах.
          Когда я снимал обычными фотоаппаратами (а не телефоном, как сейчас), заметил, что часы там спешат и через какое-то время уже знал, как быстро в каждом фотоаппарате они убегают — потом просто перед каждым запуском привязки фотографий выставлял нужное смещение. Если считать лень, можно время от времени (ну не каждый день, чё уж) фотографировать навигатор (если там есть экран, где отображаются часы с секундами) и потом уже по этой фотке смотреть разницу между временем фотоаппарата и навигатора.


  1. shoorick
    17.09.2022 16:10
    +1

    Я себе больше десяти лет назад написал скрипт для копирования и сортировки фоток — до сих пор пользуюсь.
    https://habr.com/ru/post/128527/


  1. RumataEstora
    17.09.2022 19:36

    В скрипте опечатка в имени переменной: SourcekDir (одно объявление и одно использование). В тексте статьи упоминается верно: SourceDir.


    1. VlaSard Автор
      18.09.2022 16:25

      Спасибо. Уже исправил.