-
Notifications
You must be signed in to change notification settings - Fork 14
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Support KML track import created by Pentax K-1
- Loading branch information
Showing
14 changed files
with
402 additions
and
40 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,5 +3,5 @@ | |
Store your GPX tracks of your running (or other sports activity) in django. | ||
""" | ||
|
||
__version__ = '0.17.4' | ||
__version__ = '0.18.0.dev1' | ||
__author__ = 'Jens Diemer <[email protected]>' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,109 @@ | ||
import dataclasses | ||
import datetime | ||
import logging | ||
import re | ||
|
||
import gpxpy.gpx | ||
from gpxpy.gpx import GPX, GPXTrackPoint | ||
from lxml import etree | ||
from lxml.etree import Element | ||
|
||
|
||
log = logging.getLogger(__name__) | ||
|
||
|
||
def get_single_text(node: Element, xpath: str, namespaces) -> str | None: | ||
if elements := node.xpath(xpath, namespaces=namespaces): | ||
assert len(elements) == 1, f'Expected 1 element, got {len(elements)}' | ||
return elements[0].text | ||
|
||
|
||
@dataclasses.dataclass | ||
class Coordinates: | ||
longitude: float | ||
latitude: float | ||
altitude: float | ||
|
||
|
||
def parse_coordinates(coordinates: str) -> Coordinates | None: | ||
""" | ||
>>> parse_coordinates('2.444563,51.052540,8.0') | ||
Coordinates(longitude=2.444563, latitude=51.05254, altitude=8.0) | ||
""" | ||
match = re.match(r'\s*(-?\d+\.\d+)\s*,\s*(-?\d+\.\d+)\s*,\s*(-?\d+\.\d+)\s*', coordinates) | ||
if match: | ||
lon, lat, alt = map(float, match.groups()) | ||
return Coordinates(longitude=lon, latitude=lat, altitude=alt) | ||
|
||
|
||
def get_coordinates(placemark: Element, namespaces) -> Coordinates | None: | ||
if coordinates := get_single_text(placemark, './/kml:coordinates', namespaces=namespaces): | ||
return parse_coordinates(coordinates) | ||
|
||
|
||
def parse_datetime(datetime_str) -> datetime.datetime | None: | ||
dt_part, tz_part = datetime_str.rsplit(' ', 1) | ||
|
||
try: | ||
dt = datetime.datetime.strptime(dt_part, '%Y/%m/%d %H:%M:%S') | ||
except ValueError: | ||
log.exception('Failed to parse datetime string %r', datetime_str) | ||
return None | ||
|
||
if not tz_part.startswith('UTC'): | ||
log.warning('Timezone not in UTC format: %r', tz_part) | ||
return None | ||
|
||
sign = 1 if tz_part[3] == '+' else -1 | ||
try: | ||
hours_offset = int(tz_part[4:6]) | ||
minutes_offset = int(tz_part[7:9]) | ||
except ValueError: | ||
log.exception('Failed to parse timezone offset %r', tz_part) | ||
return None | ||
|
||
tz_offset = datetime.timedelta(hours=sign * hours_offset, minutes=sign * minutes_offset) | ||
dt = dt.replace(tzinfo=datetime.timezone(tz_offset)) | ||
return dt | ||
|
||
|
||
def datetime_from_description(placemark: Element, namespaces): | ||
if description := get_single_text(placemark, 'kml:description', namespaces=namespaces): | ||
dt_str = description.partition('<br>')[0] | ||
return parse_datetime(dt_str) | ||
|
||
|
||
def kml2gpx(kml_file) -> GPX: | ||
""" | ||
Convert a KML file to a GPX object. | ||
Notes: | ||
* Only tested with KML files from a Pentax K-1 camera! | ||
""" | ||
gpx = GPX() | ||
track = gpxpy.gpx.GPXTrack() | ||
gpx.tracks.append(track) | ||
|
||
segment = gpxpy.gpx.GPXTrackSegment() | ||
track.segments.append(segment) | ||
|
||
root = etree.parse(kml_file).getroot() | ||
namespaces = {'kml': root.nsmap.get(None)} | ||
|
||
for placemark in root.xpath('//kml:Placemark', namespaces=namespaces): | ||
dt = datetime_from_description(placemark, namespaces=namespaces) | ||
if not dt: | ||
continue | ||
|
||
coordinates = get_coordinates(placemark, namespaces=namespaces) | ||
if not coordinates: | ||
continue | ||
|
||
point = GPXTrackPoint( | ||
latitude=coordinates.latitude, | ||
longitude=coordinates.longitude, | ||
elevation=coordinates.altitude, | ||
time=dt, | ||
) | ||
segment.points.append(point) | ||
|
||
return gpx |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,84 @@ | ||
from datetime import datetime, timedelta, timezone | ||
|
||
from bx_py_utils.test_utils.datetime import parse_dt | ||
from django.test import SimpleTestCase | ||
from gpxpy.gpx import GPX | ||
|
||
from for_runners.gpx_tools.kml import Coordinates, kml2gpx, parse_coordinates, parse_datetime | ||
from for_runners.tests.fixture_files import get_fixture_path | ||
|
||
|
||
class KmlTestCase(SimpleTestCase): | ||
|
||
def test_parse_coordinates(self): | ||
self.assertEqual( | ||
parse_coordinates('2.444563,51.052540,8.0'), | ||
Coordinates(longitude=2.444563, latitude=51.05254, altitude=8.0), | ||
) | ||
self.assertEqual( | ||
parse_coordinates('-2.444563,-51.052540,-8.0'), | ||
Coordinates(longitude=-2.444563, latitude=-51.05254, altitude=-8.0), | ||
) | ||
self.assertEqual( | ||
parse_coordinates(' 2.444563 , 51.052540 , 8.0 '), | ||
Coordinates(longitude=2.444563, latitude=51.05254, altitude=8.0), | ||
) | ||
self.assertIsNone(parse_coordinates('2.444563,51.052540')) | ||
self.assertIsNone(parse_coordinates('abc,def,ghi')) | ||
|
||
def test_parse_datetime(self): | ||
self.assertEqual( | ||
parse_datetime('2024/07/21 14:30:24 UTC+01:00'), | ||
datetime( | ||
2024, | ||
7, | ||
21, | ||
14, | ||
30, | ||
24, | ||
tzinfo=timezone(timedelta(hours=1)), | ||
), | ||
) | ||
self.assertEqual( | ||
parse_datetime('2024/07/21 14:30:24 UTC-05:30'), | ||
datetime( | ||
2024, | ||
7, | ||
21, | ||
14, | ||
30, | ||
24, | ||
tzinfo=timezone(timedelta(hours=-5, minutes=-30)), | ||
), | ||
) | ||
|
||
with self.assertLogs('for_runners.gpx_tools.kml', 'WARNING') as cm: | ||
self.assertIsNone(parse_datetime('2024/07/21 14:30:24')) | ||
self.assertIn('Failed to parse datetime string', '\n'.join(cm.output)) | ||
|
||
with self.assertLogs('for_runners.gpx_tools.kml', 'WARNING') as cm: | ||
self.assertIsNone(parse_datetime('21/07/2024 14:30:24 UTC+01:00')) | ||
self.assertIn('Failed to parse datetime string', '\n'.join(cm.output)) | ||
|
||
with self.assertLogs('for_runners.gpx_tools.kml', 'WARNING') as cm: | ||
self.assertIsNone(parse_datetime('2024/07/21 14:30:24 UTC+1:00')) | ||
self.assertIn('Failed to parse timezone offset', '\n'.join(cm.output)) | ||
|
||
def test_kml2gpx(self): | ||
fixture_path = get_fixture_path('PentaxK1.KML') | ||
|
||
with self.assertLogs('for_runners.gpx_tools.kml', 'WARNING'): | ||
gpx = kml2gpx(fixture_path) | ||
self.assertIsInstance(gpx, GPX) | ||
|
||
first_point = gpx.tracks[0].segments[0].points[0] | ||
self.assertEqual(first_point.latitude, 51.052540) | ||
self.assertEqual(first_point.longitude, 2.444563) | ||
self.assertEqual(first_point.elevation, 8.0) | ||
self.assertEqual(first_point.time, parse_dt('2024-07-21T14:30:24+01:00')) | ||
|
||
last_point = gpx.tracks[0].segments[0].points[-1] | ||
self.assertEqual(last_point.latitude, 50.944859) | ||
self.assertEqual(last_point.longitude, 1.847900) | ||
self.assertEqual(last_point.elevation, 14.0) | ||
self.assertEqual(last_point.time, parse_dt('2024-07-21T21:28:31+01:00')) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.