Создание приложения на Python для систематизации фото по геолокации и дате
Суть проблемы
Как-то я переустановил ОС на ноутбуке и собрал всевозможные резервные копии фотографий с разных устройств в одном месте. Получившийся каталог заслуживал только одного определения — полный бардак. Он включал резервные копии с различных телефонов и других устройств, при этом некоторые из них отличались очень сложной структурой папок. За исключением нескольких тематических названий папок, все фотографии были совершенно не отсортированы.
О сортировке вручную не могло быть и речи. Зато представился превосходный случай написать приложение для систематизации фотографий, о котором я давно подумывал. Приложение должно:
- принимать аргументы командной строки, позволяя использовать его в
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 и данными геолокации изображений. Репозиторий кода доступен по ссылке.
https://t.me/data_analysis_ml – анализ данных