QGIS API Documentation 3.37.0-Master (fdefdf9c27f)
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 std::unique_ptr< QgsProcessingParameterFeatureSink > 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 std::unique_ptr< QgsProcessingParameterFeatureSink > 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( "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
74QgsImportPhotosAlgorithm *QgsImportPhotosAlgorithm::createInstance() const
75{
76 return new QgsImportPhotosAlgorithm();
77}
78
79QVariant QgsImportPhotosAlgorithm::parseMetadataValue( const QString &value )
80{
81 const thread_local 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
90bool 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
144QVariant 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
159QVariant 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
169QVariant 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
205QVariant 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 thread_local 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
242QVariant QgsImportPhotosAlgorithm::parseCoord( const QString &string )
243{
244 const thread_local 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
259QVariantMap QgsImportPhotosAlgorithm::parseMetadataList( const QStringList &input )
260{
261 QVariantMap results;
262 const thread_local 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
280class SetEditorWidgetForPhotoAttributePostProcessor : public QgsProcessingLayerPostProcessorInterface
281{
282 public:
283
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
307QVariantMap 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 Qgis::WkbType::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", "*.heic" };
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 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 outputs.insert( QStringLiteral( "OUTPUT" ), outputDest );
448
449 if ( context.willLoadLayerOnCompletion( outputDest ) )
450 {
451 context.layerToLoadOnCompletionDetails( outputDest ).setPostProcessor( new SetEditorWidgetForPhotoAttributePostProcessor() );
452 }
453 }
454
455 if ( invalidSink )
456 outputs.insert( QStringLiteral( "INVALID" ), invalidDest );
457 return outputs;
458}
459
@ 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.
Definition: qgsattributes.h:59
This class 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:56
void setAttributes(const QgsAttributes &attrs)
Sets the feature's attributes.
Definition: qgsfeature.cpp:160
void setGeometry(const QgsGeometry &geometry)
Set the feature's geometry.
Definition: qgsfeature.cpp:167
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:45
bool append(const QgsField &field, FieldOrigin origin=OriginProvider, int originIndex=-1)
Appends a field. The field must have unique name, otherwise it is rejected (returns false)
Definition: qgsfields.cpp:59
A geometry is the spatial representation of a feature.
Definition: qgsgeometry.h:162
Base class for all map layer types.
Definition: qgsmaplayer.h:75
static QStringList cStringListToQStringList(char **stringList)
Converts a c string list to a QStringList.
A class to represent a 2D point.
Definition: qgspointxy.h:60
double y
Definition: qgspointxy.h:64
Q_GADGET 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.
Definition: qgsexception.h:83
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 data sets.
CORE_EXPORT const QStringList files(const QString &zip)
Returns the list of files within a zip file.
std::unique_ptr< std::remove_pointer< GDALDatasetH >::type, GDALDatasetCloser > dataset_unique_ptr
Scoped GDAL dataset.
Definition: qgsogrutils.h:157