Создание менеджера фотографий с поддержкой геолокации с помощью Python

Создание менеджера фотографий с поддержкой геолокации с помощью Python

https://t.me/BookPython

Суть проблемы

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

Записи базы данных, визуально представленные в DBeaver CE, отображают расположение фотографий в радиусе 50 км от города Перт 

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

  • принимать аргументы командной строки, позволяя использовать его в bash-скриптах;
  • основываться на базе данных (БД) для хранения необходимой информации;
  • сортировать и находить фотографии по дате и местоположению; 
  • распознавать людей, объекты на фото и проводить выборку изображений по этим категориям.

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

Извлечение метаданных

Первая часть кода представляет собой простой класс данных, который находит все файлы изображений в указанных папках. Для реализации этого класса применяется модуль attrs, нацеленный на улучшение его производительности по сравнению с модулем dataclasses стандартной библиотеки. Кроме того, в коде кэшируется свойство во избежание его повторного определения при каждом вызове.

from pathlib import Path
from functools import lru_cache

from attrs import define


@define(frozen=True)
class PhotoPaths:
    folders: tuple[str | Path]
    search_extensions: tuple[str, ...] = (
        ".jpg",
        ".jpeg",
        ".tif",
        ".tiff",
        ".bmp",
        ".gif",
        ".png",
        ".raw",
        ".cr2",
        ".nef",
        ".orf",
    )

    @property
    @lru_cache(maxsize=1)
    def photo_paths(self) -> tuple[Path]:
        all_files: list[Path] = []
        for fol in self.folders:
            if isinstance(fol, str):
                fol = Path(fol)
            for e in self.search_extensions:
                all_files.extend(list(fol.rglob(f"*{e}")))
        return tuple(all_files)

Класс по умолчанию ищет все стандартные (и нестандартные) расширения изображений, но такое поведение можно изменить при создании экземпляра. 

Следующая часть кода — это класс, отвечающий за извлечение метаданных из изображений. В рассматриваемом примере мы извлекаем следующие метаданные: местоположение (широту, долготу, высоту), временную метку, точность GPS (gps_accuracy), направление фотографии, а также марку и модель камеры/телефона. Зачастую применительно к моим фотографиям незаполненными остаются данные по gps_accuracy и направлению. Но я все равно их извлекаю в надежде, что они когда-нибудь пригодятся. 

В этой части кода координаты с помощью специальной функции преобразуются в десятичный формат.

В дополнении к основному классу MetadataExtractor также определяем несколько классов данных в качестве интерфейсов для совместимого формата данных и упрощения типизации. Класс данных RawCoordinates нужен для хранения необработанных координат. Они извлекаются из изображений, в которых широта и долгота хранятся в виде кортежей целых чисел (градусов, минут и секунд дуги) совместно с соответствующим ссылочным значением полушария. 

Окончательные выходные данные хранятся в объекте класса PhotoData, который для большинства параметров предоставляет предопределенные значения. Вы можете задать им любые значения, но с учетом того, что предустановленное значение для широты и долготы находится вне диапазона (-180, 180).

import datetime as dt
from pathlib import Path
from dateutil import parser

from exif import Image
from attrs import define, field
from loguru import logger

from core.image_paths import PhotoPaths


@define
class RawCoordinates:
    latitude: tuple[int, int, int]
    latitude_ref: str
    longitude: tuple[int, int, int]
    longitude_ref: str
    altitude: float


@define
class PhotoData:
    path: str
    latitude: float = field(default=-999)
    longitude: float = field(default=-999)
    altitude: float = field(default=-999)
    timestamp: dt.datetime = field(default=dt.datetime(1900, 1, 1))
    gps_accuracy: float = field(default=-999)
    photo_direction: float = field(default=-999)
    camera_make: str = field(default="unknown device")
    camera_model: str = field(default="unknown model")


class MetadataExtractor:
    def __init__(self, image_paths: PhotoPaths):
        logger.info("Collecting metadata from image files.")
        self.paths: tuple[Path] = image_paths.photo_paths

    @property
    def _raw_metadata(self) -> dict[str, Image]:
        data: dict[str, Image] = {}
        for p in self.paths:
            with open(p, "rb") as f:
                data[str(p)] = Image(f)
        return data

    @property
    def metadata(self) -> list[PhotoData]:
        res: list[PhotoData] = []
        for pth, img in self._raw_metadata.items():
            try:
                lat = self._convert_coords_to_decimal(img.gps_latitude, img.gps_latitude_ref)
                lon = self._convert_coords_to_decimal(img.gps_longitude, img.gps_longitude_ref)
                alt = img.gps_altitude
            except (AttributeError, KeyError):
                lat = lon = alt = -999

            try:
                timestamp = parser.parse(img.datetime, dayfirst=True, fuzzy=True)
            except (AttributeError, KeyError):
                timestamp = dt.datetime(1900, 1, 1)

            try:
                gps_accuracy = img.gps_horizontal_positioning_error
            except (AttributeError, KeyError):
                gps_accuracy = -999

            try:
                photo_direction = img.gps_img_direction
            except (AttributeError, KeyError):
                photo_direction = -999

            try:
                camera_make = img.make
                camera_model = img.model
            except (AttributeError, KeyError):
                camera_make = "unknown device"
                camera_model = "unknown model"
            res.append(
                PhotoData(
                    path=pth,
                    latitude=lat,
                    longitude=lon,
                    altitude=alt,
                    timestamp=timestamp,
                    gps_accuracy=gps_accuracy,
                    photo_direction=photo_direction,
                    camera_make=camera_make,
                    camera_model=camera_model,
                )
            )
        return res

    def _convert_coords_to_decimal(self, coords: tuple[float, ...], ref: str) -> float:
        """Преобразование кортежа координат в формате (градусов, минут, секунд) 
        и ссылочного значения в двоичное представление 

        Args:
            coords (tuple[float,...]): A tuple of degrees, minutes and seconds
            ref (str): Hemisphere reference of "N", "S", "E" or "W".

        Returns:
            float: A signed float of decimal representation of the coordinate.
        """
        if ref.upper() in {"W", "S"}:
            mul = -1
        elif ref.upper() in {"E", "N"}:
            mul = 1
        else:
            msg = f"Incorrect hemisphere reference. Expecting one of 'N', 'S', 'E' or 'W', got {ref} instead."
            logger.debug(msg)
            raise ValueError(msg)
        return mul * (coords[0] + coords[1] / 60 + coords[2] / 3600)

Данный код извлекает все метаданные. В случае с моей хаотичной коллекцией, насчитывающей более 10 000 фотографий в десятках папках, он делает это за 25 секунд. 

База данных PostGIS 

Установка 

Первым делом устанавливаем БД PostgreSQL на локальном хосте. Я использовал Fedora, поэтому руководствовался официальной документацией по использованию PostgreSQL

С инструкциями по установке для Windows можно ознакомиться по указанной ссылке.

Затем устанавливаем расширение PostGIS. Для этой цели в стандартных дистрибутивах Linux предоставляются: 

sudo dnf install postgis

sudo apt install postgis

Для Windows скачиваем установщик с официального сайта

На заключительном этапе задействуем psql для создания новой БД, добавляем расширение PostGIS и создаем пользователя с именем gis, который будет запускать скрипт: 

sudo -U postgres psql

В сообщении psql выводится следующая информация: 

CREATE DATABASE photo;
CREATE USER gis WITH PASSWORD gis;
\connect photo
CREATE EXTENSION postgis;
ALTER SCHEMA public OWNER TO gis;

Обычно описанных шагов достаточно. Однако при постоянно возникающих проблемах с аутентификацией следует отредактировать конфигурационный файл PostgreSQL (в соответствии с руководством) и поменять метод аутентификации с ident на md5

Создание схемы 

Создадим простую схему с 3 таблицами. Основная таблица image содержит всю ключевую информацию о фотографиях, включая путь и извлеченные метаданные. Она связана с таблицей object отношением “многие-ко-многим” через промежуточную таблицу. В перспективе таблица object будет содержать информацию о распознанных на фото объектах, а также обеспечивать поиск и отбор по категориях объект/человек. 

Пример схемы базы данных 

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

Создаем схему с помощью sqlalchemy и geoalchemy2. Для данных о местоположении используем тип Geometry из geoalchemy. Наличие 3D-координат не позволяет воспользоваться обычным объектом POINT. Потому потребуется POINTZ, который специально хранит данные высоты в третьей координате: 

from geoalchemy2 import Geometry
from sqlalchemy.orm import declarative_base, relationship
from sqlalchemy import (
    Column,
    ForeignKey,
    Integer,
    String,
    Float,
    DateTime,
    UniqueConstraint,
    Table,
    create_engine,
)

from settings.settings import load_settings

SETTINGS = load_settings()

Base = declarative_base()
engine = create_engine(SETTINGS.conn_str)


image_objects = Table(
    "image_objects",
    Base.metadata,
    Column("image_id", ForeignKey("image.id")),
    Column("object_id", ForeignKey("object.id")),
    Column("match_accuracy", Float),
)


class Image(Base):
    __tablename__ = "image"

    id = Column(Integer, primary_key=True, autoincrement=True)
    path = Column(String)
    location = Column(Geometry("POINTZ"))
    timestamp = Column(DateTime)
    gps_accuracy = Column(Float)
    photo_direction = Column(Float)
    device_make = Column(String)
    device_model = Column(String)

    objects = relationship("Object", secondary=image_objects)

    unique_path = UniqueConstraint("path", name="unique_path")


class Object(Base):
    __tablename__ = "object"

    id = Column(Integer, primary_key=True, autoincrement=True)
    object_type = Column(String)
    object_name = Column(String)
    object_path = Column(String)


def main():
    Base.metadata.create_all(engine)

Таблица object предназначена для хранения уникального id, типа объекта (машины, человека и т.д.), его названия и пути к изолированному извлеченному изображению объекта, с которым проводится сравнение. 

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

import json
from pathlib import Path

from attrs import define


@define
class Settings:
    conn_str: str


def load_settings() -> Settings:
    with open(Path().resolve() / "settings/settings.json", "r") as f:
        return Settings(**json.load(f))

Выполнение скрипта db_schema создает схему, представленную в начале данного раздела. 

API базы данных 

Приступаем к созданию API для упрощения взаимодействий с БД. Снова воспользуемся sqlalchemy, и на этом этапе потребуется один метод, позволяющий заносить данные в БД. 

Метод API принимает список объектов PhotoData, создает из них новые записи и завершает сеанс, проработав все элементы списка. Ранее я использовал значения по умолчанию для отсутствующих данных во избежание лишних сложностей с подсказками типов. Теперь при заполнении данных я проверяю эти значения и устанавливаю их в NULL в конечной записи БД. 

from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
from sqlalchemy.orm import sessionmaker

from database.schema import Image
from core.image_metadata import PhotoData, MetadataExtractor
from core.image_paths import PhotoPaths
from settings.settings import load_settings


SETTINGS = load_settings()


class DbApi:
    conn_str = SETTINGS.conn_str
    engine: Engine = create_engine(conn_str)
    session = sessionmaker(engine)

    def add_photo_to_db(self, data: list[PhotoData]):
        with self.session.begin() as sess:
            for d in data:
                if -999 not in {d.latitude, d.longitude, d.altitude}:
                    loc = f"POINTZ({d.latitude} {d.longitude} {d.altitude})"
                else:
                    loc = None
                acc = d.gps_accuracy if d.gps_accuracy != -999 else None
                direction = d.photo_direction if d.photo_direction != -999 else None

                new_result = Image(
                    path=d.path,
                    location=loc,
                    timestamp=d.timestamp,
                    gps_accuracy=acc,
                    photo_direction=direction,
                    device_make=d.camera_make,
                    device_model=d.camera_model,
                )

                sess.add(new_result)
                sess.flush()
            sess.commit()

Протестируем результат. Для этого в db_api.py добавляем нижеприведенный код. Сначала он получает пути к изображениям из выбранной папки, затем создает экземпляр класса MetadataExtractor, получает все метаданные из изображений и загружает их в БД: 

if __name__ == "__main__":
    import time

    t = time.time()
    p = PhotoPaths(("/media/storage/Photo",))
    meta = MetadataExtractor(p)

    api = DbApi()
    api.add_photo_to_db(meta.metadata)
    print(f"Completed in {time.time() - t} seconds")

Визуализация результата 

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

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

Я выбрал подмножество записей и в результате получил следующую карту: 

Расположение на карте подмножества фотографий из базы данных 

Как видно, одни фотографии были сделаны на территории Австралии, а другие — в Гонконге. 

Воспользуемся возможностью поиска и отбора данных. Например, нужно просмотреть все фотографии, сделанные в радиусе 100 км от центра города Перт, Западная Австралия. Для таких случаев PostGIS предоставляет множество предустановленных функций. 

Воспользуемся ST_DistanceSphere(), которая вычисляет расстояние между двумя точками на сферической аппроксимации поверхности Земли. Рассмотрим полный запрос, где (-31, 95, 115.86, 0) — координаты центра города Перт:

SELECT * FROM image WHERE ST_DistanceSphere(location, 'POINTZ(-31.95 115.86 0)') <= 100000

Полученный результат:

Расположение фотографий вокруг города Перт, Западная Австралия  

Поскольку все работает как надо, можно добавить метод API, который выполнит основную часть работы по отбору нужных изображений и копированию результатов в папку на диске. Код для нового метода выглядит так: 

def select_files_and_output(
        self,
        location: tuple[float, ...] = (0, 0, 0),
        distance_km: int | float = 1e10,
        start_date: dt.datetime = dt.datetime(1900, 1, 1),
        end_date: dt.datetime = dt.datetime(9999, 1, 1),
        target_folder: str | Path = "/home/pavel/Pictures/temp",
    ):
        p = Path(target_folder)
        if not p.is_dir():
            p.mkdir()

        with self.session.begin() as sess:
            q = (
                sess.query(Image.path)
                .filter(
                    Image.location.ST_DistanceSphere(
                        f"POINTZ({location[0]} {location[1]} {location[2]})"
                    )
                    <= distance_km * 1000
                )
                .filter(Image.timestamp >= start_date)
                .filter(Image.timestamp <= end_date)
            )

            res: list[str] = [i[0] for i in q.all()]

        for pth in res:
            fname = Path(pth).name
            copyfile(str(pth), str(p / fname))

Теперь можно искать фотографии только по местоположению и временному диапазону. Метод принимает исходные данные местоположения и расстояния от него, начальную/конечную дату и папку, в которую копируются результаты поиска. 

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

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

api = DbApi()res = api.select_files_and_output(location=(-31.95, 115.86, 0), distance_km=100)

Единственное отличие заключается в том, что размещаемые фотографии будут копироваться в /home/pavel/Pictures/temp.

Заключение 

Мы рассмотрели практический пример работы с БД PostGIS и данными геолокации изображений. Репозиторий кода доступен по ссылке

original

источник

Report Page