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

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

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

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

Читайте нас в TelegramVK и Яндекс.Дзен


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

Предыдущая статьяКак обнаружить дублирование кода в проекте
Следующая статьяКак уменьшить размер компонента React: 3 профессиональных приема