Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/spatial_features.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
require 'spatial_features/has_spatial_features/feature_import'

require 'spatial_features/importers/base'
require 'spatial_features/importers/exif_photo'
require 'spatial_features/importers/file'
require 'spatial_features/importers/geo_json'
require 'spatial_features/importers/esri_geo_json'
Expand Down
62 changes: 62 additions & 0 deletions lib/spatial_features/importers/exif_photo.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
require 'exifr/jpeg'
require 'ostruct'

module SpatialFeatures
module Importers
class ExifPhoto < Base
JPEG_PATTERN = /\.jpe?g\z/i.freeze
NO_PHOTOS = "This archive doesn't contain any JPEG photos.".freeze
UNREADABLE_PHOTO = "This photo couldn't be read. It may be damaged, or saved in a JPEG format we don't support.".freeze

def self.create_all(data, **options)
Download.open_each(data, unzip: JPEG_PATTERN, tmpdir: options[:tmpdir]).map do |file|
new(file.path, **options)
end
rescue Unzip::PathNotFound
raise ImportError, NO_PHOTOS
end

def initialize(data, **options)
options[:source_identifier] ||= ::File.basename(data.to_s)
super(data, **options)
end

def cache_key
@cache_key ||= Digest::MD5.file(@data).hexdigest
end

private

def each_record
photo = EXIFR::JPEG.new(@data)
gps = photo.gps
unless usable_gps?(gps)
@warnings << 'No usable GPS coordinates were found in this photo.'
return
end

yield OpenStruct.new(
name: ::File.basename(@data),
geog: "POINT(#{gps.longitude} #{gps.latitude})",
metadata: metadata_from(photo, gps),
importable_image_paths: [@data]
)
rescue EXIFR::MalformedImage
raise ImportError, UNREADABLE_PHOTO
end

def usable_gps?(gps)
gps && gps.latitude.is_a?(Numeric) && gps.longitude.is_a?(Numeric) &&
(-90..90).cover?(gps.latitude) && (-180..180).cover?(gps.longitude)
end

def metadata_from(photo, gps)
{
'capture_time' => photo.date_time_original&.strftime('%Y-%m-%d %H:%M:%S'),
'altitude' => gps.altitude&.to_s,
'camera_model' => photo.model.presence
}.compact
end
Comment thread
rywall marked this conversation as resolved.
end
end
end
1 change: 1 addition & 0 deletions spatial_features.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Gem::Specification.new do |s|
s.add_runtime_dependency "rubyzip", "~> 3.0"
s.add_runtime_dependency "nokogiri"
s.add_runtime_dependency "ostruct"
s.add_runtime_dependency "exifr"

s.add_development_dependency "rails", '>= 7', '< 9'
s.add_development_dependency "pg", '~> 1'
Expand Down
Binary file added spec/fixtures/bc25_bt_0030.JPG
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added spec/fixtures/sample_photos.zip
Binary file not shown.
142 changes: 142 additions & 0 deletions spec/lib/spatial_features/importers/exif_photo_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
require 'spec_helper'

describe SpatialFeatures::Importers::ExifPhoto do
let(:photo_path) { fixture_file_path('bc25_bt_0030.JPG') }

subject(:importer) { described_class.new(photo_path) }

let(:features) { importer.features }

describe '#features' do
it 'imports one feature from a geotagged photo' do
expect(features.count).to eq(1)
end

it 'places the feature at the EXIF GPS coordinates' do
expect(features.first.geog).to eq(
'POINT(-125.12249 50.36145)'
)
end

it 'includes EXIF metadata' do
expect(features.first.metadata).to eq(
'capture_time' => '2025-08-08 16:48:16',
'altitude' => '109.4',
'camera_model' => 'NIKON D7500'
)
end

it 'makes the photo available for attachment importing' do
expect(features.first.importable_image_paths).to eq([photo_path])
end
end

describe '#cache_key' do
it 'is based on the photo contents rather than its path' do
expect(importer.cache_key).to eq(Digest::MD5.file(photo_path).hexdigest)
end
end

context 'when the photo has no GPS coordinates' do
before do
allow(EXIFR::JPEG).to receive(:new).with(photo_path).and_return(double(gps: nil))
end

it 'does not import a feature' do
expect(features).to be_empty
end

it 'records a warning that identifies the problem' do
features

expect(importer.warnings).to include(a_string_matching(/GPS coordinates/i))
end

it 'identifies the source photo by filename' do
expect(importer.source_identifier).to eq('bc25_bt_0030.JPG')
end
end

context 'when optional EXIF metadata is absent' do
let(:gps) { double(latitude: 50.36145, longitude: -125.12249, altitude: nil) }
let(:photo) { double(gps: gps, date_time_original: nil, model: nil) }

before do
allow(EXIFR::JPEG).to receive(:new).with(photo_path).and_return(photo)
end

it 'imports the point without blank metadata values' do
expect(features.first.metadata).to eq({})
end
end

context 'when the JPEG is malformed' do
before do
allow(EXIFR::JPEG).to receive(:new).with(photo_path).and_raise(EXIFR::MalformedJPEG)
end

it 'raises an import error with a useful message' do
expect { features }
.to raise_error(SpatialFeatures::ImportError, /photo couldn't be read/i)
end
end

describe '.create_all' do
let(:tmpdir) { Dir.mktmpdir }

after do
FileUtils.remove_entry(tmpdir) if Dir.exist?(tmpdir)
end

context 'with an individual JPEG' do
it 'creates one importer' do
importers = described_class.create_all(photo_path, tmpdir: tmpdir)

expect(importers.count).to eq(1)
expect(importers.first.features.count).to eq(1)
end
end

context 'with a ZIP of JPEGs' do
subject(:importers) do
described_class.create_all(fixture_file_path('sample_photos.zip'), tmpdir: tmpdir)
end

it 'creates one importer per photo' do
expect(importers.count).to eq(5)
end

it 'imports one distinct point per photo' do
features = importers.flat_map(&:features)

expect(features.count).to eq(5)
expect(features.map(&:geog).uniq.count).to eq(5)
end

it 'preserves each photo filename' do
expect(importers.map(&:source_identifier)).to contain_exactly(
'bc25_bt_0030.JPG',
'bc25_bt_0031.JPG',
'bc25_bt_0032.JPG',
'bc25_bt_0033.JPG',
'bc25_bt_0034.JPG'
)
end

it 'keeps every extracted photo available to image handlers' do
image_paths = importers.flat_map(&:features).flat_map(&:importable_image_paths)

expect(image_paths.count).to eq(5)
expect(image_paths.all? {|path| ::File.file?(path) }).to be(true)
end
end

context 'with a ZIP containing no JPEGs' do
it 'reports that the archive has no photos' do
expect do
described_class.create_all(fixture_file_path('archive_without_any_known_file.zip'), tmpdir: tmpdir)
end.to raise_error(SpatialFeatures::ImportError, /JPEG photos/i)
end
end
end
end