Skip to content

Instantly share code, notes, and snippets.

@ajesler
Last active July 5, 2024 08:09
Show Gist options
  • Save ajesler/97cd9acf21c38562efb6503bb11c683e to your computer and use it in GitHub Desktop.
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
# 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