Найти тему
Nuances of programming

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

Оглавление

Источник: Nuances of Programming

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

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

Записи базы данных, визуально представленные в DBeaver CE, отображают расположение фотографий в радиусе 50 км от города Перт
Записи базы данных, визуально представленные в 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 и данными геолокации изображений. Репозиторий кода доступен по ссылке.

Читайте также:

Читайте нас в Telegram, VK

Перевод статьи Pavel Cherepansky: Create a Geolocation-enabled Photo Manager Using Python