QGIS API Documentation 3.43.0-Master (e01d6d7c4c0)
qgsalgorithmimportphotos.cpp
Go to the documentation of this file.
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
19#include "qgsogrutils.h"
20#include "qgsvectorlayer.h"
21#include <QDirIterator>
22#include <QFileInfo>
23#include <QRegularExpression>
24
26
27QString QgsImportPhotosAlgorithm::name() const
28{
29 return QStringLiteral( "importphotos" );
30}
31
32QString QgsImportPhotosAlgorithm::displayName() const
33{
34 return QObject::tr( "Import geotagged photos" );
35}
36
37QStringList QgsImportPhotosAlgorithm::tags() const
38{
39 return QObject::tr( "exif,metadata,gps,jpeg,jpg" ).split( ',' );
40}
41
42QString QgsImportPhotosAlgorithm::group() const
43{
44 return QObject::tr( "Vector creation" );
45}
46
47QString QgsImportPhotosAlgorithm::groupId() const
48{
49 return QStringLiteral( "vectorcreation" );
50}
51
52void QgsImportPhotosAlgorithm::initAlgorithm( const QVariantMap & )
53{
54 addParameter( new QgsProcessingParameterFile( QStringLiteral( "FOLDER" ), QObject::tr( "Input folder" ), Qgis::ProcessingFileParameterBehavior::Folder ) );
55 addParameter( new QgsProcessingParameterBoolean( QStringLiteral( "RECURSIVE" ), QObject::tr( "Scan recursively" ), false ) );
56
57 auto output = std::make_unique<QgsProcessingParameterFeatureSink>( QStringLiteral( "OUTPUT" ), QObject::tr( "Photos" ), Qgis::ProcessingSourceType::VectorPoint, QVariant(), true );
58 output->setCreateByDefault( true );
59 addParameter( output.release() );
60
61 auto invalid = std::make_unique<QgsProcessingParameterFeatureSink>( QStringLiteral( "INVALID" ), QObject::tr( "Invalid photos table" ), Qgis::ProcessingSourceType::Vector, QVariant(), true );
62 invalid->setCreateByDefault( false );
63 addParameter( invalid.release() );
64}
65
66QString QgsImportPhotosAlgorithm::shortHelpString() const
67{
68 return QObject::tr( "This algorithm creates a point layer corresponding to the geotagged locations from JPEG or HEIF/HEIC 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
74QString QgsImportPhotosAlgorithm::shortDescription() const
75{
76 return QObject::tr( "Creates a point layer corresponding to the geotagged locations from JPEG or HEIF/HEIC images from a source folder." );
77}
78
79QgsImportPhotosAlgorithm *QgsImportPhotosAlgorithm::createInstance() const
80{
81 return new QgsImportPhotosAlgorithm();
82}
83
84QVariant QgsImportPhotosAlgorithm::parseMetadataValue( const QString &value )
85{
86 const thread_local QRegularExpression numRx( QStringLiteral( "^\\s*\\(\\s*([-\\.\\d]+)\\s*\\)\\s*$" ) );
87 const QRegularExpressionMatch numMatch = numRx.match( value );
88 if ( numMatch.hasMatch() )
89 {
90 return numMatch.captured( 1 ).toDouble();
91 }
92 return value;
93}
94
95bool QgsImportPhotosAlgorithm::extractGeoTagFromMetadata( const QVariantMap &metadata, QgsPointXY &tag )
96{
97 double x = 0.0;
98 if ( metadata.contains( QStringLiteral( "EXIF_GPSLongitude" ) ) )
99 {
100 bool ok = false;
101 x = metadata.value( QStringLiteral( "EXIF_GPSLongitude" ) ).toDouble( &ok );
102 if ( !ok )
103 return false;
104
105#if QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 )
106 if ( metadata.value( QStringLiteral( "EXIF_GPSLongitudeRef" ) ).toString().rightRef( 1 ).compare( QLatin1String( "W" ), Qt::CaseInsensitive ) == 0
107 || metadata.value( QStringLiteral( "EXIF_GPSLongitudeRef" ) ).toDouble() < 0 )
108#else
109 if ( QStringView { metadata.value( QStringLiteral( "EXIF_GPSLongitudeRef" ) ).toString() }.right( 1 ).compare( QLatin1String( "W" ), Qt::CaseInsensitive ) == 0
110 || metadata.value( QStringLiteral( "EXIF_GPSLongitudeRef" ) ).toDouble() < 0 )
111#endif
112 {
113 x = -x;
114 }
115 }
116 else
117 {
118 return false;
119 }
120
121 double y = 0.0;
122 if ( metadata.contains( QStringLiteral( "EXIF_GPSLatitude" ) ) )
123 {
124 bool ok = false;
125 y = metadata.value( QStringLiteral( "EXIF_GPSLatitude" ) ).toDouble( &ok );
126 if ( !ok )
127 return false;
128
129#if QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 )
130 if ( metadata.value( QStringLiteral( "EXIF_GPSLatitudeRef" ) ).toString().rightRef( 1 ).compare( QLatin1String( "S" ), Qt::CaseInsensitive ) == 0
131 || metadata.value( QStringLiteral( "EXIF_GPSLatitudeRef" ) ).toDouble() < 0 )
132#else
133 if ( QStringView { metadata.value( QStringLiteral( "EXIF_GPSLatitudeRef" ) ).toString() }.right( 1 ).compare( QLatin1String( "S" ), Qt::CaseInsensitive ) == 0
134 || metadata.value( QStringLiteral( "EXIF_GPSLatitudeRef" ) ).toDouble() < 0 )
135#endif
136 {
137 y = -y;
138 }
139 }
140 else
141 {
142 return false;
143 }
144
145 tag = QgsPointXY( x, y );
146 return true;
147}
148
149QVariant QgsImportPhotosAlgorithm::extractAltitudeFromMetadata( const QVariantMap &metadata )
150{
151 QVariant altitude;
152 if ( metadata.contains( QStringLiteral( "EXIF_GPSAltitude" ) ) )
153 {
154 double alt = metadata.value( QStringLiteral( "EXIF_GPSAltitude" ) ).toDouble();
155 if ( metadata.contains( QStringLiteral( "EXIF_GPSAltitudeRef" ) ) && ( ( metadata.value( QStringLiteral( "EXIF_GPSAltitudeRef" ) ).userType() == QMetaType::Type::QString && metadata.value( QStringLiteral( "EXIF_GPSAltitudeRef" ) ).toString().right( 1 ) == QLatin1String( "1" ) ) || metadata.value( QStringLiteral( "EXIF_GPSAltitudeRef" ) ).toDouble() < 0 ) )
156 alt = -alt;
157 altitude = alt;
158 }
159 return altitude;
160}
161
162QVariant QgsImportPhotosAlgorithm::extractDirectionFromMetadata( const QVariantMap &metadata )
163{
164 QVariant direction;
165 if ( metadata.contains( QStringLiteral( "EXIF_GPSImgDirection" ) ) )
166 {
167 direction = metadata.value( QStringLiteral( "EXIF_GPSImgDirection" ) ).toDouble();
168 }
169 return direction;
170}
171
172QVariant QgsImportPhotosAlgorithm::extractOrientationFromMetadata( const QVariantMap &metadata )
173{
174 QVariant orientation;
175 if ( metadata.contains( QStringLiteral( "EXIF_Orientation" ) ) )
176 {
177 switch ( metadata.value( QStringLiteral( "EXIF_Orientation" ) ).toInt() )
178 {
179 case 1:
180 orientation = 0;
181 break;
182 case 2:
183 orientation = 0;
184 break;
185 case 3:
186 orientation = 180;
187 break;
188 case 4:
189 orientation = 180;
190 break;
191 case 5:
192 orientation = 90;
193 break;
194 case 6:
195 orientation = 90;
196 break;
197 case 7:
198 orientation = 270;
199 break;
200 case 8:
201 orientation = 270;
202 break;
203 }
204 }
205 return orientation;
206}
207
208QVariant QgsImportPhotosAlgorithm::extractTimestampFromMetadata( const QVariantMap &metadata )
209{
210 QVariant ts;
211 if ( metadata.contains( QStringLiteral( "EXIF_DateTimeOriginal" ) ) )
212 {
213 ts = metadata.value( QStringLiteral( "EXIF_DateTimeOriginal" ) );
214 }
215 else if ( metadata.contains( QStringLiteral( "EXIF_DateTimeDigitized" ) ) )
216 {
217 ts = metadata.value( QStringLiteral( "EXIF_DateTimeDigitized" ) );
218 }
219 else if ( metadata.contains( QStringLiteral( "EXIF_DateTime" ) ) )
220 {
221 ts = metadata.value( QStringLiteral( "EXIF_DateTime" ) );
222 }
223
224 if ( !ts.isValid() )
225 return ts;
226
227 const thread_local QRegularExpression dsRegEx( QStringLiteral( "(\\d+):(\\d+):(\\d+)\\s+(\\d+):(\\d+):(\\d+)" ) );
228 const QRegularExpressionMatch dsMatch = dsRegEx.match( ts.toString() );
229 if ( dsMatch.hasMatch() )
230 {
231 const int year = dsMatch.captured( 1 ).toInt();
232 const int month = dsMatch.captured( 2 ).toInt();
233 const int day = dsMatch.captured( 3 ).toInt();
234 const int hour = dsMatch.captured( 4 ).toInt();
235 const int min = dsMatch.captured( 5 ).toInt();
236 const int sec = dsMatch.captured( 6 ).toInt();
237 return QDateTime( QDate( year, month, day ), QTime( hour, min, sec ) );
238 }
239 else
240 {
241 return QVariant();
242 }
243}
244
245QVariant QgsImportPhotosAlgorithm::parseCoord( const QString &string )
246{
247 const thread_local QRegularExpression coordRx( QStringLiteral( "^\\s*\\(\\s*([-\\.\\d]+)\\s*\\)\\s*\\(\\s*([-\\.\\d]+)\\s*\\)\\s*\\(\\s*([-\\.\\d]+)\\s*\\)\\s*$" ) );
248 const QRegularExpressionMatch coordMatch = coordRx.match( string );
249 if ( coordMatch.hasMatch() )
250 {
251 const double hours = coordMatch.captured( 1 ).toDouble();
252 const double minutes = coordMatch.captured( 2 ).toDouble();
253 const double seconds = coordMatch.captured( 3 ).toDouble();
254 return hours + minutes / 60.0 + seconds / 3600.0;
255 }
256 else
257 {
258 return QVariant();
259 }
260}
261
262QVariantMap QgsImportPhotosAlgorithm::parseMetadataList( const QStringList &input )
263{
264 QVariantMap results;
265 const thread_local QRegularExpression splitRx( QStringLiteral( "(.*?)=(.*)" ) );
266 for ( const QString &item : input )
267 {
268 const QRegularExpressionMatch match = splitRx.match( item );
269 if ( !match.hasMatch() )
270 continue;
271
272 const QString tag = match.captured( 1 );
273 QVariant value = parseMetadataValue( match.captured( 2 ) );
274
275 if ( tag == QLatin1String( "EXIF_GPSLatitude" ) || tag == QLatin1String( "EXIF_GPSLongitude" ) )
276 value = parseCoord( value.toString() );
277 results.insert( tag, value );
278 }
279 return results;
280}
281
282
283class SetEditorWidgetForPhotoAttributePostProcessor : public QgsProcessingLayerPostProcessorInterface
284{
285 public:
287 {
288 if ( QgsVectorLayer *vl = qobject_cast<QgsVectorLayer *>( layer ) )
289 {
290 QVariantMap config;
291 // photo field shows picture viewer
292 config.insert( QStringLiteral( "DocumentViewer" ), 1 );
293 config.insert( QStringLiteral( "FileWidget" ), true );
294 config.insert( QStringLiteral( "UseLink" ), true );
295 config.insert( QStringLiteral( "FullUrl" ), true );
296 vl->setEditorWidgetSetup( vl->fields().lookupField( QStringLiteral( "photo" ) ), QgsEditorWidgetSetup( QStringLiteral( "ExternalResource" ), config ) );
297
298 config.clear();
299 // path field is a directory link
300 config.insert( QStringLiteral( "FileWidgetButton" ), true );
301 config.insert( QStringLiteral( "StorageMode" ), 1 );
302 config.insert( QStringLiteral( "UseLink" ), true );
303 config.insert( QStringLiteral( "FullUrl" ), true );
304 vl->setEditorWidgetSetup( vl->fields().lookupField( QStringLiteral( "directory" ) ), QgsEditorWidgetSetup( QStringLiteral( "ExternalResource" ), config ) );
305 }
306 }
307};
308
309QVariantMap QgsImportPhotosAlgorithm::processAlgorithm( const QVariantMap &parameters, QgsProcessingContext &context, QgsProcessingFeedback *feedback )
310{
311 const QString folder = parameterAsFile( parameters, QStringLiteral( "FOLDER" ), context );
312
313 const QDir importDir( folder );
314 if ( !importDir.exists() )
315 {
316 throw QgsProcessingException( QObject::tr( "Directory %1 does not exist!" ).arg( folder ) );
317 }
318
319 const bool recurse = parameterAsBoolean( parameters, QStringLiteral( "RECURSIVE" ), context );
320
321 QgsFields outFields;
322 outFields.append( QgsField( QStringLiteral( "photo" ), QMetaType::Type::QString ) );
323 outFields.append( QgsField( QStringLiteral( "filename" ), QMetaType::Type::QString ) );
324 outFields.append( QgsField( QStringLiteral( "directory" ), QMetaType::Type::QString ) );
325 outFields.append( QgsField( QStringLiteral( "altitude" ), QMetaType::Type::Double ) );
326 outFields.append( QgsField( QStringLiteral( "direction" ), QMetaType::Type::Double ) );
327 outFields.append( QgsField( QStringLiteral( "rotation" ), QMetaType::Type::Int ) );
328 outFields.append( QgsField( QStringLiteral( "longitude" ), QMetaType::Type::QString ) );
329 outFields.append( QgsField( QStringLiteral( "latitude" ), QMetaType::Type::QString ) );
330 outFields.append( QgsField( QStringLiteral( "timestamp" ), QMetaType::Type::QDateTime ) );
331 QString outputDest;
332 std::unique_ptr<QgsFeatureSink> outputSink( parameterAsSink( parameters, QStringLiteral( "OUTPUT" ), context, outputDest, outFields, Qgis::WkbType::PointZ, QgsCoordinateReferenceSystem( QStringLiteral( "EPSG:4326" ) ) ) );
333
334 QgsFields invalidFields;
335 invalidFields.append( QgsField( QStringLiteral( "photo" ), QMetaType::Type::QString ) );
336 invalidFields.append( QgsField( QStringLiteral( "filename" ), QMetaType::Type::QString ) );
337 invalidFields.append( QgsField( QStringLiteral( "directory" ), QMetaType::Type::QString ) );
338 invalidFields.append( QgsField( QStringLiteral( "readable" ), QMetaType::Type::Bool ) );
339 QString invalidDest;
340 std::unique_ptr<QgsFeatureSink> invalidSink( parameterAsSink( parameters, QStringLiteral( "INVALID" ), context, invalidDest, invalidFields ) );
341
342 const QStringList nameFilters { "*.jpeg", "*.jpg", "*.heic" };
343 QStringList files;
344
345 if ( !recurse )
346 {
347 const QFileInfoList fileInfoList = importDir.entryInfoList( nameFilters, QDir::NoDotAndDotDot | QDir::Files );
348 for ( auto infoIt = fileInfoList.constBegin(); infoIt != fileInfoList.constEnd(); ++infoIt )
349 {
350 files.append( infoIt->absoluteFilePath() );
351 }
352 }
353 else
354 {
355 QDirIterator it( folder, nameFilters, QDir::NoDotAndDotDot | QDir::Files, QDirIterator::Subdirectories );
356 while ( it.hasNext() )
357 {
358 it.next();
359 files.append( it.filePath() );
360 }
361 }
362
363 auto saveInvalidFile = [&invalidSink, &parameters]( QgsAttributes &attributes, bool readable ) {
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 char **GDALmetadata = GDALGetMetadata( hDS.get(), nullptr );
401 if ( !GDALmetadata )
402 {
403 GDALmetadata = GDALGetMetadata( hDS.get(), "EXIF" );
404 }
405 if ( !GDALmetadata )
406 {
407 feedback->reportError( QObject::tr( "No metadata found in %1" ).arg( QDir::toNativeSeparators( file ) ) );
408 saveInvalidFile( attributes, true );
409 }
410 else
411 {
412 if ( !outputSink )
413 continue;
414
415 QgsFeature f;
416 const QVariantMap metadata = parseMetadataList( QgsOgrUtils::cStringListToQStringList( GDALmetadata ) );
417
418 QgsPointXY tag;
419 if ( !extractGeoTagFromMetadata( metadata, tag ) )
420 {
421 // no geotag
422 feedback->reportError( QObject::tr( "Could not retrieve geotag for %1" ).arg( QDir::toNativeSeparators( file ) ) );
423 saveInvalidFile( attributes, true );
424 continue;
425 }
426
427 const QVariant altitude = extractAltitudeFromMetadata( metadata );
428 const QgsGeometry p = QgsGeometry( new QgsPoint( tag.x(), tag.y(), altitude.toDouble(), 0, Qgis::WkbType::PointZ ) );
429 f.setGeometry( p );
430
431 attributes
432 << altitude
433 << extractDirectionFromMetadata( metadata )
434 << extractOrientationFromMetadata( metadata )
435 << tag.x()
436 << tag.y()
437 << extractTimestampFromMetadata( metadata );
438 f.setAttributes( attributes );
439 if ( !outputSink->addFeature( f, QgsFeatureSink::FastInsert ) )
440 throw QgsProcessingException( writeFeatureError( outputSink.get(), parameters, QStringLiteral( "OUTPUT" ) ) );
441 }
442 }
443
444 QVariantMap outputs;
445 if ( outputSink )
446 {
447 outputSink->finalize();
448 outputs.insert( QStringLiteral( "OUTPUT" ), outputDest );
449
450 if ( context.willLoadLayerOnCompletion( outputDest ) )
451 {
452 context.layerToLoadOnCompletionDetails( outputDest ).setPostProcessor( new SetEditorWidgetForPhotoAttributePostProcessor() );
453 }
454 }
455
456 if ( invalidSink )
457 {
458 invalidSink->finalize();
459 outputs.insert( QStringLiteral( "INVALID" ), invalidDest );
460 }
461 return outputs;
462}
463
@ Vector
Tables (i.e. vector layers with or without geometry). When used for a sink this indicates the sink ha...
@ VectorPoint
Vector point layers.
@ Folder
Parameter is a folder.
@ PointZ
PointZ.
A vector of attributes.
Represents a coordinate reference system (CRS).
Holder for the widget type and its configuration for a field.
@ FastInsert
Use faster inserts, at the cost of updating the passed features to reflect changes made at the provid...
The feature class encapsulates a single feature including its unique ID, geometry and a list of field...
Definition qgsfeature.h:58
void setAttributes(const QgsAttributes &attrs)
Sets the feature's attributes.
void setGeometry(const QgsGeometry &geometry)
Set the feature's geometry.
bool isCanceled() const
Tells whether the operation has been canceled already.
Definition qgsfeedback.h:53
void setProgress(double progress)
Sets the current progress for the feedback object.
Definition qgsfeedback.h:61
Encapsulate a field in an attribute table or data source.
Definition qgsfield.h:53
Container of fields for a vector layer.
Definition qgsfields.h:46
bool append(const QgsField &field, Qgis::FieldOrigin origin=Qgis::FieldOrigin::Provider, int originIndex=-1)
Appends a field.
Definition qgsfields.cpp:70
A geometry is the spatial representation of a feature.
Base class for all map layer types.
Definition qgsmaplayer.h:77
static QStringList cStringListToQStringList(char **stringList)
Converts a c string list to a QStringList.
Represents a 2D point.
Definition qgspointxy.h:60
double y
Definition qgspointxy.h:64
double x
Definition qgspointxy.h:63
Point geometry type, with support for z-dimension and m-values.
Definition qgspoint.h:49
void setPostProcessor(QgsProcessingLayerPostProcessorInterface *processor)
Sets the layer post-processor.
Contains information about the context in which a processing algorithm is executed.
QgsProcessingContext::LayerDetails & layerToLoadOnCompletionDetails(const QString &layer)
Returns a reference to the details for a given layer which is loaded on completion of the algorithm o...
bool willLoadLayerOnCompletion(const QString &layer) const
Returns true if the given layer (by ID or datasource) will be loaded into the current project upon co...
Custom exception class for processing related exceptions.
Base class for providing feedback from a processing algorithm.
virtual void reportError(const QString &error, bool fatalError=false)
Reports that the algorithm encountered an error while executing.
An interface for layer post-processing handlers for execution following a processing algorithm operat...
virtual void postProcessLayer(QgsMapLayer *layer, QgsProcessingContext &context, QgsProcessingFeedback *feedback)=0
Post-processes the specified layer, following successful execution of a processing algorithm.
A boolean parameter for processing algorithms.
An input file or folder parameter for processing algorithms.
Represents a vector layer which manages a vector based dataset.
std::unique_ptr< std::remove_pointer< GDALDatasetH >::type, GDALDatasetCloser > dataset_unique_ptr
Scoped GDAL dataset.