1 /***************************************************************************
2                          qgsalgorithmimportphotos.cpp
3                          ------------------
4     begin                : March 2018
5     copyright            : (C) 2018 by Nyall Dawson
6     email                : nyall dot dawson at gmail dot com
7  ***************************************************************************/
8 
9 /***************************************************************************
10  *                                                                         *
11  *   This program is free software; you can redistribute it and/or modify  *
12  *   it under the terms of the GNU General Public License as published by  *
13  *   the Free Software Foundation; either version 2 of the License, or     *
14  *   (at your option) any later version.                                   *
15  *                                                                         *
16  ***************************************************************************/
17 
18 #include "qgsalgorithmimportphotos.h"
19 #include "qgsogrutils.h"
20 #include "qgsvectorlayer.h"
21 #include <QDirIterator>
22 #include <QFileInfo>
23 #include <QRegularExpression>
24 
25 ///@cond PRIVATE
26 
name() const27 QString QgsImportPhotosAlgorithm::name() const
28 {
29   return QStringLiteral( "importphotos" );
30 }
31 
displayName() const32 QString QgsImportPhotosAlgorithm::displayName() const
33 {
34   return QObject::tr( "Import geotagged photos" );
35 }
36 
tags() const37 QStringList QgsImportPhotosAlgorithm::tags() const
38 {
39   return QObject::tr( "exif,metadata,gps,jpeg,jpg" ).split( ',' );
40 }
41 
group() const42 QString QgsImportPhotosAlgorithm::group() const
43 {
44   return QObject::tr( "Vector creation" );
45 }
46 
groupId() const47 QString QgsImportPhotosAlgorithm::groupId() const
48 {
49   return QStringLiteral( "vectorcreation" );
50 }
51 
initAlgorithm(const QVariantMap &)52 void QgsImportPhotosAlgorithm::initAlgorithm( const QVariantMap & )
53 {
54   addParameter( new QgsProcessingParameterFile( QStringLiteral( "FOLDER" ), QObject::tr( "Input folder" ), QgsProcessingParameterFile::Folder ) );
55   addParameter( new QgsProcessingParameterBoolean( QStringLiteral( "RECURSIVE" ), QObject::tr( "Scan recursively" ), false ) );
56 
57   std::unique_ptr< QgsProcessingParameterFeatureSink > output = std::make_unique< QgsProcessingParameterFeatureSink >( QStringLiteral( "OUTPUT" ), QObject::tr( "Photos" ), QgsProcessing::TypeVectorPoint, QVariant(), true );
58   output->setCreateByDefault( true );
59   addParameter( output.release() );
60 
61   std::unique_ptr< QgsProcessingParameterFeatureSink > invalid = std::make_unique< QgsProcessingParameterFeatureSink >( QStringLiteral( "INVALID" ), QObject::tr( "Invalid photos table" ), QgsProcessing::TypeVector, QVariant(), true );
62   invalid->setCreateByDefault( false );
63   addParameter( invalid.release() );
64 }
65 
shortHelpString() const66 QString QgsImportPhotosAlgorithm::shortHelpString() const
67 {
68   return QObject::tr( "Creates a point layer corresponding to the geotagged locations from JPEG images from a source folder. Optionally the folder can be recursively scanned.\n\n"
69                       "The point layer will contain a single PointZ feature per input file from which the geotags could be read. Any altitude information from the geotags will be used "
70                       "to set the point's Z value.\n\n"
71                       "Optionally, a table of unreadable or non-geotagged photos can also be created." );
72 }
73 
createInstance() const74 QgsImportPhotosAlgorithm *QgsImportPhotosAlgorithm::createInstance() const
75 {
76   return new QgsImportPhotosAlgorithm();
77 }
78 
parseMetadataValue(const QString & value)79 QVariant QgsImportPhotosAlgorithm::parseMetadataValue( const QString &value )
80 {
81   const QRegularExpression numRx( QStringLiteral( "^\\s*\\(\\s*([-\\.\\d]+)\\s*\\)\\s*$" ) );
82   const QRegularExpressionMatch numMatch = numRx.match( value );
83   if ( numMatch.hasMatch() )
84   {
85     return numMatch.captured( 1 ).toDouble();
86   }
87   return value;
88 }
89 
extractGeoTagFromMetadata(const QVariantMap & metadata,QgsPointXY & tag)90 bool QgsImportPhotosAlgorithm::extractGeoTagFromMetadata( const QVariantMap &metadata, QgsPointXY &tag )
91 {
92   double x = 0.0;
93   if ( metadata.contains( QStringLiteral( "EXIF_GPSLongitude" ) ) )
94   {
95     bool ok = false;
96     x = metadata.value( QStringLiteral( "EXIF_GPSLongitude" ) ).toDouble( &ok );
97     if ( !ok )
98       return false;
99 
100 #if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
101     if ( metadata.value( QStringLiteral( "EXIF_GPSLongitudeRef" ) ).toString().rightRef( 1 ).compare( QLatin1String( "W" ), Qt::CaseInsensitive ) == 0
102          || metadata.value( QStringLiteral( "EXIF_GPSLongitudeRef" ) ).toDouble() < 0 )
103 #else
104     if ( QStringView { metadata.value( QStringLiteral( "EXIF_GPSLongitudeRef" ) ).toString() }.right( 1 ).compare( QLatin1String( "W" ), Qt::CaseInsensitive ) == 0
105          || metadata.value( QStringLiteral( "EXIF_GPSLongitudeRef" ) ).toDouble() < 0 )
106 #endif
107     {
108       x = -x;
109     }
110   }
111   else
112   {
113     return false;
114   }
115 
116   double y = 0.0;
117   if ( metadata.contains( QStringLiteral( "EXIF_GPSLatitude" ) ) )
118   {
119     bool ok = false;
120     y = metadata.value( QStringLiteral( "EXIF_GPSLatitude" ) ).toDouble( &ok );
121     if ( !ok )
122       return false;
123 
124 #if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
125     if ( metadata.value( QStringLiteral( "EXIF_GPSLatitudeRef" ) ).toString().rightRef( 1 ).compare( QLatin1String( "S" ), Qt::CaseInsensitive ) == 0
126          || metadata.value( QStringLiteral( "EXIF_GPSLatitudeRef" ) ).toDouble() < 0 )
127 #else
128     if ( QStringView { metadata.value( QStringLiteral( "EXIF_GPSLatitudeRef" ) ).toString() }.right( 1 ).compare( QLatin1String( "S" ), Qt::CaseInsensitive ) == 0
129          || metadata.value( QStringLiteral( "EXIF_GPSLatitudeRef" ) ).toDouble() < 0 )
130 #endif
131     {
132       y = -y;
133     }
134   }
135   else
136   {
137     return false;
138   }
139 
140   tag = QgsPointXY( x, y );
141   return true;
142 }
143 
extractAltitudeFromMetadata(const QVariantMap & metadata)144 QVariant QgsImportPhotosAlgorithm::extractAltitudeFromMetadata( const QVariantMap &metadata )
145 {
146   QVariant altitude;
147   if ( metadata.contains( QStringLiteral( "EXIF_GPSAltitude" ) ) )
148   {
149     double alt = metadata.value( QStringLiteral( "EXIF_GPSAltitude" ) ).toDouble();
150     if ( metadata.contains( QStringLiteral( "EXIF_GPSAltitudeRef" ) ) &&
151          ( ( metadata.value( QStringLiteral( "EXIF_GPSAltitudeRef" ) ).type() == QVariant::String && metadata.value( QStringLiteral( "EXIF_GPSAltitudeRef" ) ).toString().right( 1 ) == QLatin1String( "1" ) )
152            || metadata.value( QStringLiteral( "EXIF_GPSAltitudeRef" ) ).toDouble() < 0 ) )
153       alt = -alt;
154     altitude = alt;
155   }
156   return altitude;
157 }
158 
extractDirectionFromMetadata(const QVariantMap & metadata)159 QVariant QgsImportPhotosAlgorithm::extractDirectionFromMetadata( const QVariantMap &metadata )
160 {
161   QVariant direction;
162   if ( metadata.contains( QStringLiteral( "EXIF_GPSImgDirection" ) ) )
163   {
164     direction = metadata.value( QStringLiteral( "EXIF_GPSImgDirection" ) ).toDouble();
165   }
166   return direction;
167 }
168 
extractOrientationFromMetadata(const QVariantMap & metadata)169 QVariant QgsImportPhotosAlgorithm::extractOrientationFromMetadata( const QVariantMap &metadata )
170 {
171   QVariant orientation;
172   if ( metadata.contains( QStringLiteral( "EXIF_Orientation" ) ) )
173   {
174     switch ( metadata.value( QStringLiteral( "EXIF_Orientation" ) ).toInt() )
175     {
176       case 1:
177         orientation = 0;
178         break;
179       case 2:
180         orientation = 0;
181         break;
182       case 3:
183         orientation = 180;
184         break;
185       case 4:
186         orientation = 180;
187         break;
188       case 5:
189         orientation = 90;
190         break;
191       case 6:
192         orientation = 90;
193         break;
194       case 7:
195         orientation = 270;
196         break;
197       case 8:
198         orientation = 270;
199         break;
200     }
201   }
202   return orientation;
203 }
204 
extractTimestampFromMetadata(const QVariantMap & metadata)205 QVariant QgsImportPhotosAlgorithm::extractTimestampFromMetadata( const QVariantMap &metadata )
206 {
207   QVariant ts;
208   if ( metadata.contains( QStringLiteral( "EXIF_DateTimeOriginal" ) ) )
209   {
210     ts = metadata.value( QStringLiteral( "EXIF_DateTimeOriginal" ) );
211   }
212   else if ( metadata.contains( QStringLiteral( "EXIF_DateTimeDigitized" ) ) )
213   {
214     ts = metadata.value( QStringLiteral( "EXIF_DateTimeDigitized" ) );
215   }
216   else if ( metadata.contains( QStringLiteral( "EXIF_DateTime" ) ) )
217   {
218     ts = metadata.value( QStringLiteral( "EXIF_DateTime" ) );
219   }
220 
221   if ( !ts.isValid() )
222     return ts;
223 
224   const QRegularExpression dsRegEx( QStringLiteral( "(\\d+):(\\d+):(\\d+)\\s+(\\d+):(\\d+):(\\d+)" ) );
225   const QRegularExpressionMatch dsMatch = dsRegEx.match( ts.toString() );
226   if ( dsMatch.hasMatch() )
227   {
228     const int year = dsMatch.captured( 1 ).toInt();
229     const int month = dsMatch.captured( 2 ).toInt();
230     const int day = dsMatch.captured( 3 ).toInt();
231     const int hour = dsMatch.captured( 4 ).toInt();
232     const int min = dsMatch.captured( 5 ).toInt();
233     const int sec = dsMatch.captured( 6 ).toInt();
234     return QDateTime( QDate( year, month, day ), QTime( hour, min, sec ) );
235   }
236   else
237   {
238     return QVariant();
239   }
240 }
241 
parseCoord(const QString & string)242 QVariant QgsImportPhotosAlgorithm::parseCoord( const QString &string )
243 {
244   const QRegularExpression coordRx( QStringLiteral( "^\\s*\\(\\s*([-\\.\\d]+)\\s*\\)\\s*\\(\\s*([-\\.\\d]+)\\s*\\)\\s*\\(\\s*([-\\.\\d]+)\\s*\\)\\s*$" ) );
245   const QRegularExpressionMatch coordMatch = coordRx.match( string );
246   if ( coordMatch.hasMatch() )
247   {
248     const double hours = coordMatch.captured( 1 ).toDouble();
249     const double minutes = coordMatch.captured( 2 ).toDouble();
250     const double seconds = coordMatch.captured( 3 ).toDouble();
251     return hours + minutes / 60.0 + seconds / 3600.0;
252   }
253   else
254   {
255     return QVariant();
256   }
257 }
258 
parseMetadataList(const QStringList & input)259 QVariantMap QgsImportPhotosAlgorithm::parseMetadataList( const QStringList &input )
260 {
261   QVariantMap results;
262   const QRegularExpression splitRx( QStringLiteral( "(.*?)=(.*)" ) );
263   for ( const QString &item : input )
264   {
265     const QRegularExpressionMatch match = splitRx.match( item );
266     if ( !match.hasMatch() )
267       continue;
268 
269     const QString tag = match.captured( 1 );
270     QVariant value = parseMetadataValue( match.captured( 2 ) );
271 
272     if ( tag == QLatin1String( "EXIF_GPSLatitude" ) || tag == QLatin1String( "EXIF_GPSLongitude" ) )
273       value = parseCoord( value.toString() );
274     results.insert( tag, value );
275   }
276   return results;
277 }
278 
279 
280 class SetEditorWidgetForPhotoAttributePostProcessor : public QgsProcessingLayerPostProcessorInterface
281 {
282   public:
283 
postProcessLayer(QgsMapLayer * layer,QgsProcessingContext &,QgsProcessingFeedback *)284     void postProcessLayer( QgsMapLayer *layer, QgsProcessingContext &, QgsProcessingFeedback * ) override
285     {
286       if ( QgsVectorLayer *vl = qobject_cast< QgsVectorLayer * >( layer ) )
287       {
288         QVariantMap config;
289         // photo field shows picture viewer
290         config.insert( QStringLiteral( "DocumentViewer" ), 1 );
291         config.insert( QStringLiteral( "FileWidget" ), true );
292         config.insert( QStringLiteral( "UseLink" ), true );
293         config.insert( QStringLiteral( "FullUrl" ), true );
294         vl->setEditorWidgetSetup( vl->fields().lookupField( QStringLiteral( "photo" ) ), QgsEditorWidgetSetup( QStringLiteral( "ExternalResource" ), config ) );
295 
296         config.clear();
297         // path field is a directory link
298         config.insert( QStringLiteral( "FileWidgetButton" ), true );
299         config.insert( QStringLiteral( "StorageMode" ), 1 );
300         config.insert( QStringLiteral( "UseLink" ), true );
301         config.insert( QStringLiteral( "FullUrl" ), true );
302         vl->setEditorWidgetSetup( vl->fields().lookupField( QStringLiteral( "directory" ) ), QgsEditorWidgetSetup( QStringLiteral( "ExternalResource" ), config ) );
303       }
304     }
305 };
306 
processAlgorithm(const QVariantMap & parameters,QgsProcessingContext & context,QgsProcessingFeedback * feedback)307 QVariantMap QgsImportPhotosAlgorithm::processAlgorithm( const QVariantMap &parameters, QgsProcessingContext &context, QgsProcessingFeedback *feedback )
308 {
309   const QString folder = parameterAsFile( parameters, QStringLiteral( "FOLDER" ), context );
310 
311   const QDir importDir( folder );
312   if ( !importDir.exists() )
313   {
314     throw QgsProcessingException( QObject::tr( "Directory %1 does not exist!" ).arg( folder ) );
315   }
316 
317   const bool recurse = parameterAsBoolean( parameters, QStringLiteral( "RECURSIVE" ), context );
318 
319   QgsFields outFields;
320   outFields.append( QgsField( QStringLiteral( "photo" ), QVariant::String ) );
321   outFields.append( QgsField( QStringLiteral( "filename" ), QVariant::String ) );
322   outFields.append( QgsField( QStringLiteral( "directory" ), QVariant::String ) );
323   outFields.append( QgsField( QStringLiteral( "altitude" ), QVariant::Double ) );
324   outFields.append( QgsField( QStringLiteral( "direction" ), QVariant::Double ) );
325   outFields.append( QgsField( QStringLiteral( "rotation" ), QVariant::Int ) );
326   outFields.append( QgsField( QStringLiteral( "longitude" ), QVariant::String ) );
327   outFields.append( QgsField( QStringLiteral( "latitude" ), QVariant::String ) );
328   outFields.append( QgsField( QStringLiteral( "timestamp" ), QVariant::DateTime ) );
329   QString outputDest;
330   std::unique_ptr< QgsFeatureSink > outputSink( parameterAsSink( parameters, QStringLiteral( "OUTPUT" ), context, outputDest, outFields,
331       QgsWkbTypes::PointZ, QgsCoordinateReferenceSystem( QStringLiteral( "EPSG:4326" ) ) ) );
332 
333   QgsFields invalidFields;
334   invalidFields.append( QgsField( QStringLiteral( "photo" ), QVariant::String ) );
335   invalidFields.append( QgsField( QStringLiteral( "filename" ), QVariant::String ) );
336   invalidFields.append( QgsField( QStringLiteral( "directory" ), QVariant::String ) );
337   invalidFields.append( QgsField( QStringLiteral( "readable" ), QVariant::Bool ) );
338   QString invalidDest;
339   std::unique_ptr< QgsFeatureSink > invalidSink( parameterAsSink( parameters, QStringLiteral( "INVALID" ), context, invalidDest, invalidFields ) );
340 
341   const QStringList nameFilters { "*.jpeg", "*.jpg" };
342   QStringList files;
343 
344   if ( !recurse )
345   {
346     const QFileInfoList fileInfoList = importDir.entryInfoList( nameFilters, QDir::NoDotAndDotDot | QDir::Files );
347     for ( auto infoIt = fileInfoList.constBegin(); infoIt != fileInfoList.constEnd(); ++infoIt )
348     {
349       files.append( infoIt->absoluteFilePath() );
350     }
351   }
352   else
353   {
354     QDirIterator it( folder, nameFilters, QDir::NoDotAndDotDot | QDir::Files, QDirIterator::Subdirectories );
355     while ( it.hasNext() )
356     {
357       it.next();
358       files.append( it.filePath() );
359     }
360   }
361 
362   auto saveInvalidFile = [&invalidSink, &parameters]( QgsAttributes & attributes, bool readable )
363   {
364     if ( invalidSink )
365     {
366       QgsFeature f;
367       attributes.append( readable );
368       f.setAttributes( attributes );
369       if ( !invalidSink->addFeature( f, QgsFeatureSink::FastInsert ) )
370         throw QgsProcessingException( writeFeatureError( invalidSink.get(), parameters, QStringLiteral( "INVALID" ) ) );
371     }
372   };
373 
374   const double step = files.count() > 0 ? 100.0 / files.count() : 1;
375   int i = 0;
376   for ( const QString &file : files )
377   {
378     i++;
379     if ( feedback->isCanceled() )
380     {
381       break;
382     }
383 
384     feedback->setProgress( i * step );
385 
386     const QFileInfo fi( file );
387     QgsAttributes attributes;
388     attributes << QDir::toNativeSeparators( file )
389                << fi.completeBaseName()
390                << QDir::toNativeSeparators( fi.absolutePath() );
391 
392     const gdal::dataset_unique_ptr hDS( GDALOpen( file.toUtf8().constData(), GA_ReadOnly ) );
393     if ( !hDS )
394     {
395       feedback->reportError( QObject::tr( "Could not open %1" ).arg( QDir::toNativeSeparators( file ) ) );
396       saveInvalidFile( attributes, false );
397       continue;
398     }
399 
400     if ( char **GDALmetadata = GDALGetMetadata( hDS.get(), nullptr ) )
401     {
402       if ( !outputSink )
403         continue;
404 
405       QgsFeature f;
406       const QVariantMap metadata = parseMetadataList( QgsOgrUtils::cStringListToQStringList( GDALmetadata ) );
407 
408       QgsPointXY tag;
409       if ( !extractGeoTagFromMetadata( metadata, tag ) )
410       {
411         // no geotag
412         feedback->reportError( QObject::tr( "Could not retrieve geotag for %1" ).arg( QDir::toNativeSeparators( file ) ) );
413         saveInvalidFile( attributes, true );
414         continue;
415       }
416 
417       const QVariant altitude = extractAltitudeFromMetadata( metadata );
418       const QgsGeometry p = QgsGeometry( new QgsPoint( tag.x(), tag.y(), altitude.toDouble(), 0, QgsWkbTypes::PointZ ) );
419       f.setGeometry( p );
420 
421       attributes
422           << altitude
423           << extractDirectionFromMetadata( metadata )
424           << extractOrientationFromMetadata( metadata )
425           << tag.x()
426           << tag.y()
427           << extractTimestampFromMetadata( metadata );
428       f.setAttributes( attributes );
429       if ( !outputSink->addFeature( f, QgsFeatureSink::FastInsert ) )
430         throw QgsProcessingException( writeFeatureError( outputSink.get(), parameters, QStringLiteral( "OUTPUT" ) ) );
431     }
432     else
433     {
434       feedback->reportError( QObject::tr( "No metadata found in %1" ).arg( QDir::toNativeSeparators( file ) ) );
435       saveInvalidFile( attributes, true );
436     }
437   }
438 
439   QVariantMap outputs;
440   if ( outputSink )
441   {
442     outputs.insert( QStringLiteral( "OUTPUT" ), outputDest );
443 
444     if ( context.willLoadLayerOnCompletion( outputDest ) )
445     {
446       context.layerToLoadOnCompletionDetails( outputDest ).setPostProcessor( new SetEditorWidgetForPhotoAttributePostProcessor() );
447     }
448   }
449 
450   if ( invalidSink )
451     outputs.insert( QStringLiteral( "INVALID" ), invalidDest );
452   return outputs;
453 }
454 
455 ///@endcond
456