File indexing completed on 2024-04-28 15:39:05

0001 // SPDX-FileCopyrightText: 2020-2023 Tobias Leupold <tl at stonemx dot de>
0002 //
0003 // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
0004 
0005 // Local includes
0006 
0007 #include "GpxEngine.h"
0008 #include "GeoDataModel.h"
0009 #include "Logging.h"
0010 
0011 #include "debugMode.h"
0012 
0013 // Marble includes
0014 #include <marble/GeoDataCoordinates.h>
0015 
0016 // Qt includes
0017 
0018 #include <QDebug>
0019 #include <QFile>
0020 #include <QXmlStreamReader>
0021 #include <QJsonDocument>
0022 #include <QStandardPaths>
0023 #include <QFile>
0024 #include <QLoggingCategory>
0025 #include <QTimeZone>
0026 
0027 // C++ includes
0028 #include <cmath>
0029 
0030 static const auto s_gpx    = QStringLiteral("gpx");
0031 static const auto s_trk    = QStringLiteral("trk");
0032 static const auto s_trkpt  = QStringLiteral("trkpt");
0033 static const auto s_lon    = QStringLiteral("lon");
0034 static const auto s_lat    = QStringLiteral("lat");
0035 static const auto s_ele    = QStringLiteral("ele");
0036 static const auto s_time   = QStringLiteral("time");
0037 static const auto s_trkseg = QStringLiteral("trkseg");
0038 
0039 GpxEngine::GpxEngine(QObject *parent, GeoDataModel *geoDataModel)
0040     : QObject(parent),
0041       m_geoDataModel(geoDataModel)
0042 {
0043     // Load the timezone map image
0044     const auto timezoneMapFile = QStandardPaths::locate(QStandardPaths::AppDataLocation,
0045                                                         QStringLiteral("timezones.png"));
0046     if (! timezoneMapFile.isEmpty()) {
0047         m_timezoneMap = QImage(timezoneMapFile);
0048         if (! m_timezoneMap.isNull()) {
0049             m_timezoneMapWidth = m_timezoneMap.size().width();
0050             m_timezoneMapHeight = m_timezoneMap.size().height();
0051         }
0052         qCDebug(KGeoTagLog) << "Loaded the timezones map from" << timezoneMapFile;
0053     } else {
0054         // This should not happen
0055         qCDebug(KGeoTagLog) << "Failed to load the timezones map file!";
0056     }
0057 
0058     // Load the color-timezone mapping
0059     const auto timezoneMappingFile = QStandardPaths::locate(QStandardPaths::AppDataLocation,
0060                                                             QStringLiteral("timezones.json"));
0061     if (! timezoneMappingFile.isEmpty()) {
0062         QFile jsonData(timezoneMappingFile);
0063         if (jsonData.open(QIODevice::ReadOnly | QIODevice::Text)) {
0064             const auto jsonDocument = QJsonDocument::fromJson(jsonData.readAll());
0065             jsonData.close();
0066             m_timezoneMapping = jsonDocument.object();
0067         }
0068         qCDebug(KGeoTagLog) << "Loaded the timezone mapping data from" << timezoneMappingFile;
0069     } else {
0070         // This should not happen
0071         qCDebug(KGeoTagLog) << "Failed to load the timezone mapping data file!";
0072     }
0073 
0074     // Check if all listed timezones are valid
0075 
0076     if (! m_timezoneMapping.isEmpty()) {
0077         const auto allTimeZones = QTimeZone::availableTimeZoneIds();
0078         const auto keys = m_timezoneMapping.keys();
0079 
0080         QVector<QByteArray> invalidIds;
0081 
0082         for (const auto &key : keys) {
0083             const auto timeZoneId = m_timezoneMapping.value(key).toString().toUtf8();
0084             if (! allTimeZones.contains(timeZoneId)) {
0085                 invalidIds.append(timeZoneId);
0086             }
0087         }
0088 
0089         qCDebug(KGeoTagLog) << "Processed" << m_timezoneMapping.count() << "timezone IDs";
0090 
0091         if (invalidIds.count() > 0) {
0092             qCWarning(KGeoTagLog) << "Found" << invalidIds.count() << "unusable timezone ID(s)!";
0093             qCWarning(KGeoTagLog) << "    The following IDs are not represented in "
0094                                   << "QTimeZone::availableTimeZoneIds():";
0095             for (const auto &id : invalidIds) {
0096                 qCWarning(KGeoTagLog) << "   " << id;
0097             }
0098         }
0099 
0100     } else {
0101         // This should not happen
0102         qCWarning(KGeoTagLog) << "Could not load any timezone IDs!";
0103     }
0104 }
0105 
0106 GpxEngine::LoadInfo GpxEngine::load(const QString &path)
0107 {
0108     if (m_geoDataModel->contains(path)) {
0109         return { LoadResult::AlreadyLoaded };
0110     }
0111 
0112     QFile gpxFile(path);
0113 
0114     if (! gpxFile.open(QIODevice::ReadOnly | QIODevice::Text)) {
0115         return { LoadResult::OpenFailed };
0116     }
0117 
0118     QXmlStreamReader xml(&gpxFile);
0119 
0120     double lon = 0.0;
0121     double lat = 0.0;
0122     double alt = 0.0;
0123     QDateTime time;
0124 
0125     QVector<QDateTime> segmentTimes;
0126     QVector<Coordinates> segmentCoordinates;
0127 
0128     QVector<QVector<Coordinates>> allSegments;
0129     QVector<QVector<QDateTime>> allSegmentTimes;
0130 
0131     bool gpxFound = false;
0132     bool trackStartFound = false;
0133 
0134     int tracks = 0;
0135     int segments = 0;
0136     int points = 0;
0137 
0138     while (! xml.atEnd()) {
0139         if (xml.hasError()) {
0140             return { LoadResult::XmlError };
0141         }
0142 
0143         const QXmlStreamReader::TokenType token = xml.readNext();
0144         const QStringRef name = xml.name();
0145 
0146         if (token == QXmlStreamReader::StartElement) {
0147             if (! gpxFound) {
0148                 if (name != s_gpx) {
0149                     continue;
0150                 } else {
0151                     gpxFound = true;
0152                 }
0153             }
0154 
0155             if (! trackStartFound) {
0156                 if (name != s_trk) {
0157                     continue;
0158                 } else {
0159                     trackStartFound = true;
0160                     tracks++;
0161                 }
0162             }
0163 
0164             if (name == s_trkseg) {
0165                 segments++;
0166 
0167             } else if (name == s_trkpt) {
0168                 QXmlStreamAttributes attributes = xml.attributes();
0169                 lon = attributes.value(s_lon).toDouble();
0170                 lat = attributes.value(s_lat).toDouble();
0171                 points++;
0172 
0173             } else if (name == s_ele) {
0174                 xml.readNext();
0175                 alt = xml.text().toDouble();
0176 
0177             } else if (name == s_time) {
0178                 xml.readNext();
0179                 time = QDateTime::fromString(xml.text().toString(), Qt::ISODate);
0180 
0181                 // Strip out milliseconds if the GPX provides them to allow seconds-exact matching
0182                 const auto msec = time.time().msec();
0183                 if (msec != 0) {
0184                     time = time.addMSecs(msec * -1);
0185                 }
0186             }
0187 
0188         } else if (token == QXmlStreamReader::EndElement) {
0189             if (name == s_trkpt) {
0190                 segmentTimes.append(time);
0191                 segmentCoordinates.append(Coordinates(lon, lat, alt, true));
0192                 alt = 0.0;
0193                 time = QDateTime();
0194 
0195             } else if (name == s_trkseg && ! segmentCoordinates.isEmpty()) {
0196                 allSegmentTimes.append(segmentTimes);
0197                 allSegments.append(segmentCoordinates);
0198                 segmentTimes.clear();
0199                 segmentCoordinates.clear();
0200 
0201             } else if (name == s_trk) {
0202                 trackStartFound = false;
0203             }
0204         }
0205     }
0206 
0207     if (! gpxFound) {
0208         return { LoadResult::NoGpxElement, tracks, segments, points };
0209     }
0210 
0211     if (points == 0) {
0212         return { LoadResult::NoGeoData, tracks, segments, points };
0213     }
0214 
0215     // All okay :-)
0216 
0217     // Pass the loaded data to the GeoDataModel
0218     m_geoDataModel->addTrack(path, allSegmentTimes, allSegments);
0219 
0220     // Detect the presumable timezone the corresponding photos were taken in
0221 
0222     // Get the loaded path's bounding box's center point
0223     const auto trackCenter = m_geoDataModel->trackBoxCenter(path);
0224 
0225     // Scale the coordinates to the image size, relative to the image center
0226     int mappedLon = std::round(trackCenter.lon() / 180.0 * (m_timezoneMapWidth / 2.0));
0227     int mappedLat = std::round(trackCenter.lat() / 90.0 * (m_timezoneMapHeight / 2.0));
0228 
0229     // Move the mapped coordinates to the left lower edge
0230     mappedLon = m_timezoneMapWidth / 2 + mappedLon;
0231     mappedLat = m_timezoneMapHeight - (m_timezoneMapHeight / 2 + mappedLat);
0232 
0233     // Get the respective pixel's color
0234     const auto timezoneColor = m_timezoneMap.pixelColor(mappedLon, mappedLat).name();
0235 
0236     // Lookup the corresponding timezone
0237     const auto timezoneId = m_timezoneMapping.value(timezoneColor);
0238     if (timezoneId.isString()) {
0239         m_lastDetectedTimeZoneId = timezoneId.toString().toUtf8();
0240     } else {
0241         m_lastDetectedTimeZoneId.clear();
0242     }
0243 
0244     return { LoadResult::Okay, tracks, segments, points };
0245 }
0246 
0247 void GpxEngine::setMatchParameters(int exactMatchTolerance, int maximumInterpolationInterval,
0248                                    int maximumInterpolationDistance)
0249 {
0250     m_exactMatchTolerance = exactMatchTolerance;
0251     m_maximumInterpolationInterval = maximumInterpolationInterval;
0252     m_maximumInterpolationDistance = maximumInterpolationDistance;
0253 }
0254 
0255 Coordinates GpxEngine::findExactCoordinates(const QDateTime &time, int deviation) const
0256 {
0257     if (deviation == 0) {
0258         return findExactCoordinates(time);
0259     } else {
0260         const QDateTime fixedTime = time.addSecs(deviation);
0261         return findExactCoordinates(fixedTime);
0262     }
0263 }
0264 
0265 Coordinates GpxEngine::findExactCoordinates(const QDateTime &time) const
0266 {
0267     // Iterate over all loaded files we have
0268     for (const auto &trackPoints : m_geoDataModel->trackPoints()) {
0269         // Check for an exact match
0270         if (trackPoints.contains(time)) {
0271             return trackPoints.value(time);
0272         }
0273 
0274         // Check for a match with +/- the maximum tolerable deviation
0275         for (int i = 1; i <= m_exactMatchTolerance; i++) {
0276             const auto timeBefore = time.addSecs(i * -1);
0277             if (trackPoints.contains(timeBefore)) {
0278                 return trackPoints.value(timeBefore);
0279             }
0280             const auto timeAfter = time.addSecs(i);
0281             if (trackPoints.contains(timeAfter)) {
0282                 return trackPoints.value(timeAfter);
0283             }
0284         }
0285     }
0286 
0287     // No match found
0288     return Coordinates();
0289 }
0290 
0291 Coordinates GpxEngine::findInterpolatedCoordinates(const QDateTime &time, int deviation) const
0292 {
0293     if (deviation == 0) {
0294         return findInterpolatedCoordinates(time);
0295     } else {
0296         const QDateTime fixedTime = time.addSecs(deviation);
0297         return findInterpolatedCoordinates(fixedTime);
0298     }
0299 }
0300 
0301 Coordinates GpxEngine::findInterpolatedCoordinates(const QDateTime &time) const
0302 {
0303     // Iterate over all loaded files we have
0304     for (int i = 0; i < m_geoDataModel->dateTimes().count(); i++) {
0305         const auto &dateTimes = m_geoDataModel->dateTimes().at(i);
0306         const auto &trackPoints = m_geoDataModel->trackPoints().at(i);
0307 
0308         // This only works if we at least have at least 2 points ;-)
0309         if (dateTimes.count() < 2) {
0310             continue;
0311         }
0312 
0313         // If the image's date is before the first or after the last point we have,
0314         // it can't be assigned.
0315         if (time < dateTimes.first() || time > dateTimes.last()) {
0316             continue;
0317         }
0318 
0319         // Check for an exact match (without tolerance)
0320         // This also eliminates the case that the time could be the first one.
0321         // We thus can be sure the first entry in dateTimes is earlier than the time requested.
0322         if (dateTimes.contains(time)) {
0323             return trackPoints.value(time);
0324         }
0325 
0326         // Search for the first time earlier than the image's
0327 
0328         int start = 0;
0329         int end = dateTimes.count() - 1;
0330         int index = 0;
0331         int lastIndex = -1;
0332 
0333         while (true) {
0334             index = start + (end - start) / 2;
0335             if (index == lastIndex) {
0336                 break;
0337             }
0338 
0339             if (dateTimes.at(index) > time) {
0340                 end = index;
0341             } else {
0342                 start = index;
0343             }
0344 
0345             lastIndex = index;
0346         }
0347 
0348         // If the found point is the last one, we can't interpolate and use it directly
0349         const auto &closestBefore = dateTimes.at(index);
0350         if (closestBefore == dateTimes.last()) {
0351             return trackPoints.value(closestBefore);
0352         }
0353 
0354         // Interpolate between the two coordinates
0355 
0356         const auto &closestAfter = dateTimes.at(index + 1);
0357 
0358         // Check for a maximum time interval between the points if requested
0359         if (m_maximumInterpolationInterval != -1
0360             && closestBefore.secsTo(closestAfter) > m_maximumInterpolationInterval) {
0361 
0362             continue;
0363         }
0364 
0365         // Create Marble coordinates from the cache for further calculations
0366         const auto &pointBefore = trackPoints[closestBefore];
0367         const auto &pointAfter = trackPoints[closestAfter];
0368         const auto coordinatesBefore = Marble::GeoDataCoordinates(
0369             pointBefore.lon(), pointBefore.lat(), pointBefore.alt(),
0370             Marble::GeoDataCoordinates::Degree);
0371         const auto coordinatesAfter = Marble::GeoDataCoordinates(
0372             pointAfter.lon(), pointAfter.lat(), pointAfter.alt(),
0373             Marble::GeoDataCoordinates::Degree);
0374 
0375         // Check for a maximum distance between the points if requested
0376 
0377         if (m_maximumInterpolationDistance != -1
0378             && coordinatesBefore.sphericalDistanceTo(coordinatesAfter) * KGeoTag::earthRadius
0379             > m_maximumInterpolationDistance) {
0380 
0381             continue;
0382         }
0383 
0384         // Calculate an interpolated position between the coordinates
0385 
0386         const int secondsBefore = closestBefore.secsTo(time);
0387         const double fraction = double(secondsBefore)
0388                                 / double(secondsBefore + time.secsTo(closestAfter));
0389         const auto interpolated = coordinatesBefore.interpolate(coordinatesAfter, fraction);
0390 
0391         return Coordinates(interpolated.longitude(Marble::GeoDataCoordinates::Degree),
0392                         interpolated.latitude(Marble::GeoDataCoordinates::Degree),
0393                         interpolated.altitude(),
0394                         true);
0395     }
0396 
0397     // No match found
0398     return Coordinates();
0399 }
0400 
0401 QByteArray GpxEngine::lastDetectedTimeZoneId() const
0402 {
0403     return m_lastDetectedTimeZoneId;
0404 }
0405 
0406 bool GpxEngine::timeZoneDataLoaded() const
0407 {
0408     return ! m_timezoneMap.isNull() && ! m_timezoneMapping.isEmpty();
0409 }
0410 
0411 QPair<Coordinates, QDateTime> GpxEngine::findClosestTrackPoint(QDateTime time,
0412                                                                int cameraClockDeviation) const
0413 {
0414     if (cameraClockDeviation != 0) {
0415         time = time.addSecs(cameraClockDeviation);
0416     }
0417 
0418     auto coordinates = Coordinates();
0419     auto pointTime = QDateTime();
0420     auto deviation = -1;
0421 
0422     // Iterate over all loaded files we have
0423     for (const auto &trackPoints : m_geoDataModel->trackPoints()) {
0424         if (deviation == -1) {
0425             // Initialize the deviation with the first point
0426             const auto it = trackPoints.constBegin();
0427             deviation = std::abs(it.key().secsTo(time));
0428             pointTime = it.key();
0429             coordinates = it.value();
0430         }
0431 
0432         // Check for an exact match
0433         if (trackPoints.contains(time)) {
0434             return QPair<Coordinates, QDateTime>(trackPoints.value(time), time);
0435         }
0436 
0437         // Check for the time deviation of each point
0438         QHash<QDateTime, Coordinates>::const_iterator it;
0439         for (it = trackPoints.constBegin(); it != trackPoints.constEnd(); it++) {
0440             const auto currentDeviaton = std::abs(it.key().secsTo(time));
0441             if (currentDeviaton < deviation) {
0442                 deviation = currentDeviaton;
0443                 pointTime = it.key();
0444                 coordinates = it.value();
0445             }
0446         }
0447     }
0448 
0449     return QPair<Coordinates, QDateTime>(coordinates, pointTime);
0450 }