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