Last active
July 5, 2024 08:09
-
-
Save ajesler/97cd9acf21c38562efb6503bb11c683e to your computer and use it in GitHub Desktop.
Takes a set of photos and gpx tracks, and sorts them into before and after shots of common locations
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# A quick and dirty script to match a bunch of before and after photos of locations. The location of each | |
# photo is matched by reading timestamps out of a GPX file and finding the closest point in | |
# the track (time wise) to the time the photo was taken. | |
# It then groups the photos based on a distance in meters threshold (LocalityGrouper distance parameter). | |
# The more accurate your GPS and the more frequent your point entries in the GPX track, the | |
# better this will work. | |
# | |
# This script assumes your photos have different dates for the before and after. If this isn't | |
# the case, you'll need to change the logic used for calculating grouped_photos | |
# | |
# This script assumes you have a directory structure like | |
# | |
# workspace-dir | |
# group_before_and_after_shots.rb | |
# raw/ | |
# photos/ | |
# 1.jpg | |
# 2.jpg | |
# .. | |
# any-name.jpg | |
# tracks/ | |
# friday.gpx | |
# saturday.gpx | |
# processed/ (the contents get wiped and re-created during each script run) | |
# | |
# It is assumed that the photos have EXIF data set with the original photo capture time | |
# | |
# There are faster and better ways to do the grouping, but this simple algorithm worked for my purposes. | |
# Grouping is non-deterministic because the photo order is randomly shuffled to start the grouping. | |
# Install dependencies | |
require 'bundler/inline' | |
gemfile true do | |
source 'https://rubygems.org' | |
gem 'gpx' | |
gem 'rgeo' | |
gem 'exiftool' | |
end | |
class Photo | |
attr_reader :path, :timestamp | |
attr_accessor :locality | |
def initialize(path:, timestamp:) | |
@path = path | |
@timestamp = timestamp | |
@locality = nil | |
end | |
def inspect | |
"Photo path=#{path} timestamp=#{timestamp} locality=#{locality}" | |
end | |
end | |
class PhotoLoader | |
attr_reader :dir, :photos | |
def initialize(dir:) | |
@dir = dir | |
end | |
def load | |
search_pattern = File.join(dir, '*.jpg') | |
exif = Exiftool.new(Dir[search_pattern]) | |
@photos = Dir.glob(search_pattern).map do |path| | |
e = exif.result_for(path) | |
timestamp = Time.strptime(e[:date_time_original], '%Y:%m:%d %H:%M:%S') | |
Photo.new(path:, timestamp:) | |
end | |
end | |
end | |
TimedLocation = Data.define(:timestamp, :point) | |
class LocalityAtTime | |
attr_reader :tracks | |
def initialize(tracks:) | |
@tracks = tracks | |
build_time_list | |
end | |
def locality(time) | |
@locations_by_time.each_cons(2) do |pre, post| | |
# Search for a poit of points that have ta timestamp range that includes the photo time | |
next unless pre.timestamp <= time && post.timestamp >= time | |
# Returns whichever point is closest in time | |
return pre.point if (time - pre.timestamp).abs <= (time - post.timestamp).abs | |
return post.point | |
end | |
end | |
private | |
def build_time_list | |
@locations_by_time = [] | |
factory = RGeo::Geographic.spherical_factory(srid: 4326) | |
tracks.each do |track_path| | |
gpx_track = GPX::GPXFile.new(gpx_file: track_path) | |
gpx_track.tracks[0].points.each do |point| | |
latlon = factory.point(point.lon, point.lat) | |
@locations_by_time << TimedLocation.new( | |
timestamp: point.time.localtime, | |
point: latlon | |
) | |
end | |
end | |
@locations_by_time.sort_by!(&:timestamp) | |
end | |
end | |
class LocalityGrouper | |
attr_reader :groups, :photos, :threshold_distance | |
# distance is in meters | |
def initialize(photos:, distance:) | |
@photos = photos | |
@threshold_distance = distance | |
end | |
def group | |
group_by_location | |
.select { |same_location| same_location && same_location.size > 1 } | |
end | |
private | |
def group_by_location | |
skip = Set.new | |
photos.map do |photo| | |
next if skip.include?(photo) | |
same_location = [photo] | |
photos.each do |other_photo| | |
next if photo == other_photo | |
distance = photo.locality.distance(other_photo.locality) | |
next unless distance < threshold_distance | |
# puts "#{distance} between #{photo.path}@#{photo.timestamp} and #{photo.path}@#{other_photo.timestamp}" | |
same_location << other_photo | |
skip << other_photo | |
end | |
same_location | |
end | |
end | |
end | |
# Execution | |
photo_directory = 'raw/photos' | |
tracks = ['raw/tracks/friday.gpx', 'raw/tracks/saturday.gpx'] | |
photo_loader = PhotoLoader.new(dir: photo_directory).tap(&:load) | |
# Photos are shuffled here because the grouping changes based on where the start point is | |
# considered to be. The start point is taken from the first photo processsed. | |
# So if we change the order, we get different starting points each time. | |
photos = photo_loader.photos.shuffle | |
# Load a locality for each photo based on the tracks time & location | |
lat = LocalityAtTime.new(tracks:) | |
photos.each do |photo| | |
locality = lat.locality(photo.timestamp) | |
if locality | |
photo.locality = locality | |
else | |
puts "Couldn't find a locality for #{photo.timestamp} (#{photo.path})" | |
end | |
end | |
grouped_photos = LocalityGrouper | |
.new(photos:, distance: 15) | |
.group | |
# We only want groups with a before and after photo | |
# If day is all the same, then we only have a before or after photo for a location not both | |
.select { |group| group.map { |p| p.timestamp.day }.uniq.size > 1 } | |
puts "Found #{grouped_photos.size} photo groups" | |
grouped_photos.each do |group| | |
puts "Found #{group.size} photos around #{group.first.locality}" | |
end | |
# Clean out the processed groups | |
FileUtils.rm_rf(Dir.glob('processed/*')) | |
# Generate group file names and copy the source photos into processed/ with the new name | |
# The output files will be named like | |
# group_1_around_175.2942451648414_-41.002975730225444_before_0.jpg | |
# group_1_around_175.2942451648414_-41.002975730225444_after_1.jpg | |
# group_2_around_175.25879063643515_-41.048883916810155_before_0.jpg | |
# group_2_around_175.25879063643515_-41.048883916810155_before_1.jpg | |
# group_2_around_175.25879063643515_-41.048883916810155_after_2.jpg | |
# group_2_around_175.25879063643515_-41.048883916810155_after_3.jpg | |
# group_2_around_175.25879063643515_-41.048883916810155_after_4.jpg | |
grouped_photos.each.with_index do |group, group_index| | |
location = group.first.locality.coordinates.join('_') | |
name_prefix = "group_#{group_index}_around_#{location}" | |
group.sort_by { |photo| photo.timestamp.to_date.to_s }.each.with_index do |photo, index| | |
before_or_after = photo.timestamp.friday? ? 'before' : 'after' | |
extension = File.extname(photo.path) # Includes the '.' | |
new_name = "#{name_prefix}_#{before_or_after}_#{index}#{extension}" | |
FileUtils.cp(photo.path, File.join('processed', new_name)) | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment