0
|
1 |
#!/usr/bin/env python
|
|
2 |
# -*- coding: utf8 -*-
|
|
3 |
|
|
4 |
'''
|
|
5 |
gpx_reduce v1.8: removes points from gpx-files to reduce filesize and
|
|
6 |
tries to keep introduced distortions to the track at a minimum.
|
|
7 |
Copyright (C) 2011,2012,2013,2015,2016,2017 travelling_salesman on OpenStreetMap
|
|
8 |
|
|
9 |
changelog: v1.2: clarity refractoring + speedup for identical points
|
|
10 |
v1.3: new track weighting functions, progress display
|
|
11 |
v1.4: new track weighting function, restructuring for memory saving
|
|
12 |
v1.5: algorithm speedup by roughly a factor of 2 by eliminating some cases.
|
|
13 |
v1.6: presets for train etc.
|
|
14 |
v1.7: introduced weighting function for elevation errors
|
|
15 |
v1.8: speed-dependent distance limit
|
|
16 |
|
|
17 |
This program is free software: you can redistribute it and/or modify
|
|
18 |
it under the terms of the GNU General Public License as published by
|
|
19 |
the Free Software Foundation, either version 3 of the License, or
|
|
20 |
(at your option) any later version.
|
|
21 |
|
|
22 |
This program is distributed in the hope that it will be useful,
|
|
23 |
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
24 |
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
25 |
GNU General Public License for more details.
|
|
26 |
|
|
27 |
You should have received a copy of the GNU General Public License
|
|
28 |
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
29 |
'''
|
|
30 |
|
|
31 |
import datetime
|
|
32 |
import sys
|
|
33 |
import time
|
|
34 |
import pylab as pl
|
|
35 |
import scipy as sc
|
|
36 |
import numpy.linalg as la
|
|
37 |
from math import *
|
|
38 |
from lxml import etree
|
|
39 |
from optparse import OptionParser
|
|
40 |
|
|
41 |
|
|
42 |
parser = OptionParser('usage: %prog [options] input-file.gpx')
|
|
43 |
parser.add_option('-v', '--verbose', action='store', type='int',
|
|
44 |
dest='verbose', default=1, help='verbose=[0,1]')
|
|
45 |
parser.add_option('-p', '--plot', action='store_true', dest='plot',
|
|
46 |
default=False, help='Show a plot of the result at the end.')
|
|
47 |
parser.add_option('-d', '--dist', action='store', type='float', dest='max_dist',
|
|
48 |
default=0.5, help='Maximum distance of line from original points in meters')
|
|
49 |
parser.add_option('-o', '--out', action='store', type='string',
|
|
50 |
dest='ofname', default=None, help='Output file name')
|
|
51 |
parser.add_option('-m', '--maxsep', action='store', type='float', dest='max_sep',
|
|
52 |
default=200.0, help='Absolute maximum separation of points. No points will be deleted where the resulting distance would become greater than maxsep. Standard JOSM settings will not display points spaced more than 200m. -1 means no limit.')
|
|
53 |
parser.add_option('-n', '--maxsep0', action='store', type='float', dest='max_sep0',
|
|
54 |
default=4.0, help='Maximum separation of points at zero speed.')
|
|
55 |
parser.add_option('-t', '--maxseptime', action='store', type='float', dest='max_sep_time',
|
|
56 |
default=3.0, help='Maximum time separation of points, which will be added to maxsep0. Maximum allowed point separation will be min(m, n + t*v) where v is the speed in m/s.')
|
|
57 |
parser.add_option('-e', '--ele-weight', action='store', type='float',
|
|
58 |
dest='ele_weight', default=0.0,
|
|
59 |
help='Weighting of elevation errors vs. horizontal errors. Default is 0.')
|
|
60 |
parser.add_option('-b', '--bend', action='store', type='float', dest='bend',
|
|
61 |
default=1.0, help='Penalty value for large bending angles at each trackpoint. Larger values (1 or more) make the track smoother.')
|
|
62 |
parser.add_option('-w', '--weighting', action='store', type='string',
|
|
63 |
dest='weighting', default='exp',
|
|
64 |
help='''Weighting function to be minimized for track reduction:
|
|
65 |
pnum (number of points),
|
|
66 |
sqrdistsum (number of points plus sum of squared distances to leftout points),
|
|
67 |
sqrdistmax (number of points plus sum of squared distances to each maximally separated leftout point per new line segment),
|
|
68 |
sqrlength (number of points plus sum of squared new line segment lengths normalized by maxsep),
|
|
69 |
mix (number of points plus sum of squared distances to each maximally separated leftout point per new line segment weighted with corresponding segment length),
|
|
70 |
exp (number of points plus sum of squared distances to leftout points with exponential weighting of 1/2, 1/4, 1/8... from furthest to closest point). exp=standard''')
|
|
71 |
(options, args) = parser.parse_args()
|
|
72 |
|
|
73 |
|
|
74 |
if len(args) < 1:
|
|
75 |
parser.print_usage()
|
|
76 |
exit(2)
|
|
77 |
|
|
78 |
|
|
79 |
# use the WGS-84 ellipsoid
|
|
80 |
rE = 6356752.314245 # earth's radius
|
|
81 |
a = 6378137.0
|
|
82 |
b = 6356752.314245179
|
|
83 |
|
|
84 |
timeformat = '%Y-%m-%dT%H:%M:%SZ'
|
|
85 |
|
|
86 |
|
|
87 |
def distance(p1_, pm_, p2_, ele_weight=1.0):
|
|
88 |
# returns distance of pm from line between p1 and p2
|
|
89 |
|
|
90 |
p1, pm, p2 = sc.array(p1_), sc.array(pm_), sc.array(p2_)
|
|
91 |
h1, hm, h2 = la.norm(p1), la.norm(pm), la.norm(p2)
|
|
92 |
if ele_weight != 1.0 and min(h1, hm, h2) > 0.0:
|
|
93 |
hmean = (h1 + hm + h2) / 3.0
|
|
94 |
p1 *= (ele_weight + (1.0 - ele_weight) * hmean / h1)
|
|
95 |
pm *= (ele_weight + (1.0 - ele_weight) * hmean / hm)
|
|
96 |
p2 *= (ele_weight + (1.0 - ele_weight) * hmean / h2)
|
|
97 |
line = p2 - p1
|
|
98 |
linel = la.norm(line)
|
|
99 |
vm = pm - p1
|
|
100 |
if linel == 0.0:
|
|
101 |
return la.norm(vm)
|
|
102 |
linem = line / linel
|
|
103 |
|
|
104 |
position = pl.dot(vm, linem) / linel
|
|
105 |
if position < 0.0:
|
|
106 |
return la.norm(vm)
|
|
107 |
elif position > 1.0:
|
|
108 |
return la.norm(pm - p2)
|
|
109 |
else:
|
|
110 |
return la.norm(vm - line * position)
|
|
111 |
|
|
112 |
|
|
113 |
def rotate(x, y, phi):
|
|
114 |
return x*cos(phi) - y*sin(phi), x*sin(phi) + y*cos(phi)
|
|
115 |
|
|
116 |
|
|
117 |
def project_to_meters(lat, lon, latm, lonm):
|
|
118 |
# azimuthal map projection centered at average track coordinate
|
|
119 |
lon -= lonm
|
|
120 |
xyz = latlonele_to_xyz(lat, lon, 0.0)
|
|
121 |
zy = rotate(xyz[2], xyz[1], radians(90 - latm))
|
|
122 |
lat2 = atan2(zy[0], la.norm([zy[1], xyz[0]]))
|
|
123 |
lon2 = atan2(xyz[0], -zy[1])
|
|
124 |
x_meters = rE * sin(lon2) * (pi / 2.0 - lat2)
|
|
125 |
y_meters = -rE * cos(lon2) * (pi / 2.0 - lat2)
|
|
126 |
return x_meters, y_meters
|
|
127 |
|
|
128 |
|
|
129 |
def latlonele_to_xyz(lat, lon, ele):
|
|
130 |
s = sin(radians(lat))
|
|
131 |
c = cos(radians(lat))
|
|
132 |
r = ele + a * b / la.norm([s*a, c*b])
|
|
133 |
lon = radians(lon)
|
|
134 |
return r * c * sin(lon), r * c * (-cos(lon)), r * s
|
|
135 |
|
|
136 |
|
|
137 |
def xyz_to_latlonele(x, y, z):
|
|
138 |
r = la.norm([x, y, z])
|
|
139 |
if (r == 0):
|
|
140 |
return 0.0, 0.0, 0.0
|
|
141 |
lat = degrees(atan2(z, la.norm([x, y])))
|
|
142 |
lon = degrees(atan2(x, -y))
|
|
143 |
ele = r * (1.0 - a * b / la.norm([a*z, b*x, b*y]))
|
|
144 |
return lat, lon, ele
|
|
145 |
|
|
146 |
|
|
147 |
def reduced_track_indices(coordinate_list, timesteps=None):
|
|
148 |
# returns a list of indices of trackpoints that constitute the reduced track
|
|
149 |
# takes a list of kartesian coordinate tuples
|
|
150 |
m = len(coordinate_list)
|
|
151 |
if (m == 0): return []
|
|
152 |
if timesteps != None and len(timesteps) != len(coordinate_list):
|
|
153 |
timesteps = None
|
|
154 |
|
|
155 |
# number of dimensions
|
|
156 |
d = len(coordinate_list[0])
|
|
157 |
|
|
158 |
# remove identical entries (can speed up algorithm considerably)
|
|
159 |
original_indices = [0]
|
|
160 |
points = [{'p': coordinate_list[0], 'weight':1}]
|
|
161 |
if timesteps != None: points[0]['t'] = timesteps[0]
|
|
162 |
for i in range(1, m):
|
|
163 |
if False in [coordinate_list[i-1][j] == coordinate_list[i][j] for j in range(d)]:
|
|
164 |
original_indices.append(i)
|
|
165 |
points.append({'p': coordinate_list[i], 'weight':1})
|
|
166 |
if timesteps != None: points[-1]['t'] = timesteps[i]
|
|
167 |
else:
|
|
168 |
points[-1]['weight'] += 1
|
|
169 |
n = len(points)
|
|
170 |
|
|
171 |
# progress printing initialisations
|
|
172 |
progress_printed = False
|
|
173 |
progress = None
|
|
174 |
tprint = time.time()
|
|
175 |
|
|
176 |
# execute Dijkstra-like algorithm on points
|
|
177 |
points[0]['cost'] = 1.0
|
|
178 |
points[0]['prev'] = -1
|
|
179 |
|
|
180 |
for i2 in range(1, n):
|
|
181 |
penalties = {}
|
|
182 |
imin = None
|
|
183 |
costmin = float('inf')
|
|
184 |
for i1 in reversed(range(i2)):
|
|
185 |
p1 = sc.array(points[i1]['p'])
|
|
186 |
p2 = sc.array(points[i2]['p'])
|
|
187 |
seglength = la.norm(p2 - p1)
|
|
188 |
|
|
189 |
# estimate speed between p1 and p2
|
|
190 |
if timesteps != None:
|
|
191 |
dt = (points[i2]['t'] - points[i1]['t']).total_seconds()
|
|
192 |
v = seglength / max(0.1, dt)
|
|
193 |
else:
|
|
194 |
v = seglength / float(i2 - i1) # assume 1s time spacing
|
|
195 |
|
|
196 |
max_sep = options.max_sep0 + v * options.max_sep_time
|
|
197 |
if options.max_dist >= 0:
|
|
198 |
max_sep = min(max_sep, options.max_sep)
|
|
199 |
|
|
200 |
if (seglength >= max_sep and i1 != i2 - 1):
|
|
201 |
# point separation is too far
|
|
202 |
# but always accept direct predecessor i1 = i2 - 1
|
|
203 |
if (seglength >= max_sep + options.max_dist):
|
|
204 |
# no chance to find a valid earlier predecessor point
|
|
205 |
break
|
|
206 |
else:
|
|
207 |
continue
|
|
208 |
|
|
209 |
if points[i1]['cost'] + 1.0 > costmin:
|
|
210 |
# the possible predecessor i1 is already too bad.
|
|
211 |
continue
|
|
212 |
|
|
213 |
i1_i2_segment_valid = True
|
|
214 |
lower_i1_possible = True
|
|
215 |
distance_squaremax = 0.0
|
|
216 |
distance_squaresum = 0.0
|
|
217 |
distances_squared = []
|
|
218 |
# iterate all medium points between i1 and i2
|
|
219 |
for im in range(i1+1, i2):
|
|
220 |
pm = sc.array(points[im]['p'])
|
|
221 |
d = distance(p1, pm, p2, options.ele_weight)
|
|
222 |
if (d <= options.max_dist):
|
|
223 |
d_sq = (d / options.max_dist) ** 2
|
|
224 |
distance_squaremax = max(distance_squaremax, d_sq)
|
|
225 |
distance_squaresum += points[im]['weight'] * d_sq
|
|
226 |
distances_squared.append(d_sq)
|
|
227 |
else:
|
|
228 |
i1_i2_segment_valid = False
|
|
229 |
|
|
230 |
# check if connection to any further point i1 is impossible
|
|
231 |
d1 = pl.dot(p1 - p2, p1 - p2)
|
|
232 |
d2 = pl.dot(pm - p2, pm - p2)
|
|
233 |
dd = options.max_dist ** 2
|
|
234 |
d1d2 = pl.dot(p1 - p2, pm - p2)
|
|
235 |
# formula from cosines of point separation angle and cone-opening angles around points
|
|
236 |
if (d1 > dd and d2 > dd and (d1d2 + dd)**2 < (d2 - dd) * (d1 - dd)):
|
|
237 |
lower_i1_possible = False
|
|
238 |
break
|
|
239 |
|
|
240 |
if (lower_i1_possible == False):
|
|
241 |
break
|
|
242 |
|
|
243 |
if i1_i2_segment_valid:
|
|
244 |
if options.weighting == 'sqrdistmax':
|
|
245 |
penalties[i1] = distance_squaremax
|
|
246 |
elif options.weighting == 'sqrdistsum':
|
|
247 |
penalties[i1] = distance_squaresum
|
|
248 |
elif options.weighting == 'sqrlength':
|
|
249 |
penalties[i1] = (seglength / max_sep) ** 2
|
|
250 |
elif options.weighting == 'mix':
|
|
251 |
penalties[i1] = (distance_squaremax * (1.0 + seglength / max_sep))
|
|
252 |
elif options.weighting == 'exp':
|
|
253 |
penalties[i1] = 0.5 * sum([0.5**i * d for i, d in
|
|
254 |
enumerate(sorted(distances_squared, reverse=True))])
|
|
255 |
else:
|
|
256 |
penalties[i1] = 0.0
|
|
257 |
|
|
258 |
# add a penalty for kinks
|
|
259 |
if options.bend > 0.:
|
|
260 |
if points[i1]['prev'] != -1:
|
|
261 |
p0 = sc.array(points[points[i1]['prev']]['p'])
|
|
262 |
v0 = p1 - p0
|
|
263 |
v1 = p2 - p1
|
|
264 |
if la.norm(v0) > 0. and la.norm(v1) > 0.:
|
|
265 |
v0 /= la.norm(v0)
|
|
266 |
v1 /= la.norm(v1)
|
|
267 |
kink = (1.0 - sc.dot(v0, v1)) / 2.0
|
|
268 |
penalties[i1] += options.bend * kink
|
|
269 |
|
|
270 |
# find best predecessor
|
|
271 |
imin = None
|
|
272 |
costmin = float('inf')
|
|
273 |
for prev, penalty in penalties.iteritems():
|
|
274 |
# cost function is sum of points used (1.0) plus penalties
|
|
275 |
cost = points[prev]['cost'] + 1.0 + penalty
|
|
276 |
if cost < costmin:
|
|
277 |
imin = prev
|
|
278 |
costmin = cost
|
|
279 |
points[i2]['cost'] = costmin
|
|
280 |
points[i2]['prev'] = imin
|
|
281 |
|
|
282 |
# print progess
|
|
283 |
if options.verbose == 1 and (100 * i2) / n > progress and time.time() >= tprint + 1:
|
|
284 |
tprint = time.time()
|
|
285 |
progress = (100 * i2) / n
|
|
286 |
print '\r', progress, '%',
|
|
287 |
sys.stdout.flush()
|
|
288 |
progress_printed = True
|
|
289 |
|
|
290 |
if progress_printed:
|
|
291 |
print '\r',
|
|
292 |
|
|
293 |
# trace route backwards to collect final points
|
|
294 |
final_pnums = []
|
|
295 |
i = n-1
|
|
296 |
while i >= 0:
|
|
297 |
final_pnums = [i] + final_pnums
|
|
298 |
i = points[i]['prev']
|
|
299 |
|
|
300 |
return [original_indices[i] for i in final_pnums]
|
|
301 |
|
|
302 |
|
|
303 |
|
|
304 |
############################## main function #################################
|
|
305 |
for fname in args:
|
|
306 |
# initialisations
|
|
307 |
tracksegs_old = []
|
|
308 |
tracksegs_new = []
|
|
309 |
sumx, sumy, sumz = 0.0, 0.0, 0.0
|
|
310 |
|
|
311 |
# import xml data from files
|
|
312 |
print 'opening file', fname
|
|
313 |
infile = open(fname)
|
|
314 |
|
|
315 |
tree = etree.parse(infile)
|
|
316 |
infile.close()
|
|
317 |
gpx = tree.getroot()
|
|
318 |
if gpx.nsmap.has_key(None):
|
|
319 |
nsmap = '{' + gpx.nsmap[None] + '}'
|
|
320 |
else:
|
|
321 |
nsmap = ''
|
|
322 |
|
|
323 |
# extract data from xml
|
|
324 |
for trkseg in gpx.findall('.//' + nsmap + 'trkseg'):
|
|
325 |
trkpts = trkseg.findall(nsmap + 'trkpt')
|
|
326 |
n = len(trkpts)
|
|
327 |
|
|
328 |
# extract coordinate values
|
|
329 |
lats = [float(trkpt.get('lat')) for trkpt in trkpts]
|
|
330 |
lons = [float(trkpt.get('lon')) for trkpt in trkpts]
|
|
331 |
eles = [float(trkpt.find(nsmap + 'ele').text) for trkpt in trkpts]
|
|
332 |
try:
|
|
333 |
times = [datetime.datetime.strptime(trkpt.find(nsmap + 'time'
|
|
334 |
).text, timeformat) for trkpt in trkpts]
|
|
335 |
except Exception as e:
|
|
336 |
print e
|
|
337 |
times = None
|
|
338 |
|
|
339 |
# save original trackseg for plotting
|
|
340 |
if options.plot:
|
|
341 |
tracksegs_old.append([[lats[i], lons[i], eles[i]] for i in range(n)])
|
|
342 |
|
|
343 |
# calculate projected points to work on
|
|
344 |
coords = []
|
|
345 |
for i in range(n):
|
|
346 |
x, y, z = latlonele_to_xyz(lats[i], lons[i], eles[i])
|
|
347 |
coords.append((x, y, z))
|
|
348 |
sumx += x
|
|
349 |
sumy += y
|
|
350 |
sumz += z
|
|
351 |
|
|
352 |
# execute the reduction algorithm
|
|
353 |
final_pnums = reduced_track_indices(coords, times)
|
|
354 |
|
|
355 |
n_new = len(final_pnums)
|
|
356 |
print 'number of points:', n, '-', n - n_new, '=', n_new
|
|
357 |
|
|
358 |
# delete certain points from original data
|
|
359 |
delete_pnums = [i for i in range(n) if i not in final_pnums]
|
|
360 |
for i in reversed(delete_pnums):
|
|
361 |
del trkseg[trkseg.index(trkpts[i])]
|
|
362 |
|
|
363 |
# save reduced trackseg for plotting
|
|
364 |
if options.plot:
|
|
365 |
tracksegs_new.append([[float(trkpt.get('lat')),
|
|
366 |
float(trkpt.get('lon')), float(trkpt.find(nsmap + 'ele').text)]
|
|
367 |
for trkpt in trkseg.findall(nsmap + 'trkpt')])
|
|
368 |
|
|
369 |
|
|
370 |
# export data to file
|
|
371 |
if options.ofname != None:
|
|
372 |
ofname = options.ofname
|
|
373 |
elif fname.endswith('.gpx'):
|
|
374 |
ofname = fname[:-4] + '_reduced.gpx'
|
|
375 |
else:
|
|
376 |
ofname = fname + '_reduced.gpx'
|
|
377 |
outfile = open(ofname, 'w')
|
|
378 |
outfile.write(etree.tostring(tree, xml_declaration=True,
|
|
379 |
pretty_print=True, encoding='utf-8'))
|
|
380 |
outfile.close()
|
|
381 |
print 'modified copy written to', ofname
|
|
382 |
|
|
383 |
|
|
384 |
# plot result to screen
|
|
385 |
if options.plot:
|
|
386 |
latm, lonm, elesum = xyz_to_latlonele(sumx, sumy, sumz)
|
|
387 |
|
|
388 |
for trkseg in tracksegs_old:
|
|
389 |
y_old = []
|
|
390 |
x_old = []
|
|
391 |
for trkpt in trkseg:
|
|
392 |
xy = project_to_meters(trkpt[0], trkpt[1], latm, lonm)
|
|
393 |
x_old.append(xy[0])
|
|
394 |
y_old.append(xy[1])
|
|
395 |
pl.plot(x_old, y_old, 'r.-')
|
|
396 |
|
|
397 |
for trkseg in tracksegs_new:
|
|
398 |
y_new = []
|
|
399 |
x_new = []
|
|
400 |
for trkpt in trkseg:
|
|
401 |
xy = project_to_meters(trkpt[0], trkpt[1], latm, lonm)
|
|
402 |
x_new.append(xy[0])
|
|
403 |
y_new.append(xy[1])
|
|
404 |
pl.plot(x_new, y_new, 'b.-')
|
|
405 |
pl.grid()
|
|
406 |
pl.gca().set_aspect('equal')
|
|
407 |
pl.xlabel('x [m]')
|
|
408 |
pl.ylabel('y [m]')
|
|
409 |
pl.show() |