├── README.md ├── polysimplify.py └── tests ├── fuzzy_circle.npy ├── fuzzy_thresholds.npy └── tests.py /README.md: -------------------------------------------------------------------------------- 1 | Visvalingam-Whyatt polyline simplification 2 | ===================== 3 | 4 | Efficient Pure Python implementation of Visvalingam and Whyatt's 5 | algorithm for reducing the complexity of poly-lines. Also 6 | includes precision decimation to reduce file sizes if desired. 7 | 8 | Works with GDAL OGRGeometry LINESTRING, POLYGON and MULTIPOLYGON 9 | objects as well as lists of vertices. 10 | 11 | This method ranks the verticies by their importance to the 12 | shape (how much removing the affects the area) in a 13 | non-destructive manner. Once the ranking has been done, 14 | filtering of points is ultra fast (just one numpy mask 15 | operation). 16 | 17 | However, even for just one filtering operation, this 18 | method seems to be faster than Ramer-Douglas-Peucker. 19 | 20 | from polysimplify import VWSimplifier 21 | import numpy as np 22 | from time import time 23 | 24 | n = 5000 25 | thetas = np.linspace(0,2*np.pi,n) 26 | pts = np.array([[np.sin(x),np.cos(x)] for x in thetas]) 27 | 28 | start=time() 29 | simplifier = VWSimplifier(pts) 30 | VWpts = simplifier.from_number(n/100) 31 | end = time() 32 | print "Visvalingam: reduced to %s points in %03f seconds" %(len(VWpts),end-start) 33 | #50 points in .131 seconds on my computer 34 | 35 | 36 | from rdp import rdp 37 | start=time() 38 | RDPpts = rdp(pts,epsilon=.00485) #found by trail and error 39 | end = time() 40 | print "Ramer-Douglas-Peucker: to %s points in %023 seconds" %(len(RDPpts),end-start) 41 | #40 points in 1.35 seconds on my computer 42 | -------------------------------------------------------------------------------- /polysimplify.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Visvalingam-Whyatt method of poly-line vertex reduction 3 | 4 | Visvalingam, M and Whyatt J D (1993) 5 | "Line Generalisation by Repeated Elimination of Points", Cartographic J., 30 (1), 46 - 51 6 | 7 | Described here: 8 | http://web.archive.org/web/20100428020453/http://www2.dcs.hull.ac.uk/CISRG/publications/DPs/DP10/DP10.html 9 | 10 | ========================================= 11 | 12 | The MIT License (MIT) 13 | 14 | Copyright (c) 2014 Elliot Hallmark 15 | 16 | Permission is hereby granted, free of charge, to any person obtaining a copy 17 | of this software and associated documentation files (the "Software"), to deal 18 | in the Software without restriction, including without limitation the rights 19 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 20 | copies of the Software, and to permit persons to whom the Software is 21 | furnished to do so, subject to the following conditions: 22 | 23 | The above copyright notice and this permission notice shall be included in all 24 | copies or substantial portions of the Software. 25 | 26 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 27 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 28 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 29 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 30 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 31 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 32 | SOFTWARE. 33 | 34 | ================================ 35 | ''' 36 | 37 | from numpy import array, argmin 38 | import numpy as np 39 | 40 | 41 | def triangle_area(p1,p2,p3): 42 | """ 43 | calculates the area of a triangle given its vertices 44 | """ 45 | return abs(p1[0]*(p2[1]-p3[1])+p2[0]*(p3[1]-p1[1])+p3[0]*(p1[1]-p2[1]))/2. 46 | 47 | def triangle_areas_from_array(arr): 48 | ''' 49 | take an (N,2) array of points and return an (N,1) 50 | array of the areas of those triangles, where the first 51 | and last areas are np.inf 52 | 53 | see triangle_area for algorithm 54 | ''' 55 | 56 | result = np.empty((len(arr),),arr.dtype) 57 | result[0] = np.inf; result[-1] = np.inf 58 | 59 | p1 = arr[:-2] 60 | p2 = arr[1:-1] 61 | p3 = arr[2:] 62 | 63 | #an accumulators to avoid unnecessary intermediate arrays 64 | accr = result[1:-1] #Accumulate directly into result 65 | acc1 = np.empty_like(accr) 66 | 67 | np.subtract(p2[:,1], p3[:,1], out = accr) 68 | np.multiply(p1[:,0], accr, out = accr) 69 | np.subtract(p3[:,1], p1[:,1], out = acc1 ) 70 | np.multiply(p2[:,0], acc1, out = acc1 ) 71 | np.add(acc1, accr, out = accr) 72 | np.subtract(p1[:,1], p2[:,1], out = acc1 ) 73 | np.multiply(p3[:,0], acc1, out = acc1 ) 74 | np.add(acc1, accr, out = accr) 75 | np.abs(accr, out = accr) 76 | accr /= 2. 77 | #Notice: accr was writing into result, so the answer is in there 78 | return result 79 | 80 | #the final value in thresholds is np.inf, which will never be 81 | # the min value. So, I am safe in "deleting" an index by 82 | # just shifting the array over on top of it 83 | def remove(s,i): 84 | ''' 85 | Quick trick to remove an item from a numpy array without 86 | creating a new object. Rather than the array shape changing, 87 | the final value just gets repeated to fill the space. 88 | 89 | ~3.5x faster than numpy.delete 90 | ''' 91 | s[i:-1]=s[i+1:] 92 | 93 | class VWSimplifier(object): 94 | 95 | def __init__(self,pts): 96 | '''Initialize with points. takes some time to build 97 | the thresholds but then all threshold filtering later 98 | is ultra fast''' 99 | self.pts = np.array(pts) 100 | self.thresholds = self.build_thresholds() 101 | self.ordered_thresholds = sorted(self.thresholds,reverse=True) 102 | 103 | def build_thresholds(self): 104 | '''compute the area value of each vertex, which one would 105 | use to mask an array of points for any threshold value. 106 | 107 | returns a numpy.array (length of pts) of the areas. 108 | ''' 109 | pts = self.pts 110 | nmax = len(pts) 111 | real_areas = triangle_areas_from_array(pts) 112 | real_indices = range(nmax) 113 | 114 | 115 | #destructable copies 116 | #ARG! areas=real_areas[:] doesn't make a copy! 117 | areas = np.copy(real_areas) 118 | i = real_indices[:] 119 | 120 | #pick first point and set up for loop 121 | min_vert = argmin(areas) 122 | this_area = areas[min_vert] 123 | # areas and i are modified for each point finished 124 | remove(areas,min_vert) #faster 125 | #areas = np.delete(areas,min_vert) #slower 126 | real_idx = i.pop(min_vert) 127 | 128 | #cntr = 3 129 | while this_area 1: 165 | #cant try/except because 0-1=-1 is a valid index 166 | left_area = triangle_area(pts[i[min_vert-2]], 167 | pts[i[min_vert-1]],pts[i[min_vert]]) 168 | if left_area <= this_area: 169 | #same justification as above 170 | left_area = this_area 171 | skip = min_vert-1 172 | real_areas[i[min_vert-1]] = left_area 173 | areas[min_vert-1] = left_area 174 | 175 | 176 | #only argmin if we have too. 177 | min_vert = skip or argmin(areas) 178 | real_idx = i.pop(min_vert) 179 | this_area = areas[min_vert] 180 | #areas = np.delete(areas,min_vert) #slower 181 | remove(areas,min_vert) #faster 182 | '''if sum(np.where(areas==np.inf)[0]) != sum(list(reversed(range(len(areas))))[:cntr]): 183 | print "broke:",np.where(areas==np.inf)[0],cntr 184 | break 185 | cntr+=1 186 | #if real_areas[0]= threshold] 193 | 194 | def from_number(self,n): 195 | thresholds = self.ordered_thresholds 196 | try: 197 | threshold = thresholds[int(n)] 198 | except IndexError: 199 | return self.pts 200 | return self.pts[self.thresholds > threshold] 201 | 202 | def from_ratio(self,r): 203 | if r<=0 or r>1: 204 | raise ValueError("Ratio must be 0 threshold],precision=precision) 229 | return arr.replace('[[ ','(').replace(']]',')').replace(']\n [ ',',') 230 | ''' 231 | def wkt_from_threshold(self,threshold, precision=None): 232 | if precision: 233 | self.set_precision(precision) 234 | pts = self.pts_as_strs[self.thresholds >= threshold] 235 | return '(%s)'%','.join(['%s %s'%(x,y) for x,y in pts]) 236 | 237 | def wkt_from_number(self,n,precision=None): 238 | thresholds = self.ordered_thresholds 239 | if n<3: n=3 #For polygons. TODO something better 240 | try: 241 | threshold = thresholds[int(n)] 242 | except IndexError: 243 | threshold = 0 244 | 245 | return self.wkt_from_threshold(threshold,precision=precision) 246 | 247 | def wkt_from_ratio(self,r,precision=None): 248 | if r<=0 or r>1: 249 | raise ValueError("Ratio must be 0 413962.65495176613 277 | gdalsimplifierpoly.area -> 413962.65495339036 278 | ''' 279 | def __init__(self,geom,precision=None,return_GDAL = True): 280 | '''accepts a gdal.OGRGeometry or geos.GEOSGeometry 281 | object and wraps multiple 282 | VWSimplifiers. set return_GDAL to False for faster 283 | filtering with arrays of floats returned instead of 284 | geometry objects.''' 285 | global p 286 | self.return_GDAL = return_GDAL 287 | if isinstance(geom,OGRGeometry): 288 | name = geom.geom_name 289 | self.Geometry = lambda w: OGRGeometry(w,srs=geom.srs) 290 | self.pts = np.array(geom.tuple) 291 | elif isinstance(geom,GEOSGeometry): 292 | name = geom.geom_type.upper() 293 | self.Geometry = lambda w: fromstr(w) 294 | self.pts = np.array(geom.tuple) 295 | elif isinstance(geom, unicode) or isinstance(geom,str): 296 | #assume wkt 297 | #for WKT 298 | def str2tuple(q): 299 | return '(%s,%s)' % (q.group(1),q.group(2)) 300 | 301 | self.return_GDAL = False #don't even try 302 | self.Geometry = lambda w: w #this will never be used 303 | name, pts = geom.split(' ',1) 304 | self.pts = loads(p.sub(str2tuple,pts).\ 305 | replace('(','[').replace(')',']')) 306 | self.precision = precision 307 | if name == 'LINESTRING': 308 | self.maskfunc = self.linemask 309 | self.buildfunc = self.linebuild 310 | self.fromnumfunc = self.notimplemented 311 | elif name == "POLYGON": 312 | self.maskfunc = self.polymask 313 | self.buildfunc = self.polybuild 314 | self.fromnumfunc = self.notimplemented 315 | elif name == "MULTIPOLYGON": 316 | self.maskfunc = self.multimask 317 | self.buildfunc = self.multibuild 318 | self.fromnumfunc = self.notimplemented 319 | else: 320 | raise OGRGeometryError(""" 321 | Only types LINESTRING, POLYGON and MULTIPOLYGON 322 | supported, but got %s"""%name) 323 | #sets self.simplifiers to a list of VWSimplifiers 324 | self.buildfunc() 325 | 326 | #rather than concise, I'd rather be explicit and clear. 327 | 328 | def pt2str(self,pt): 329 | '''make length 2 numpy.array.__str__() fit for wkt''' 330 | return ' '.join(pt) 331 | 332 | def linebuild(self): 333 | self.simplifiers = [WKTSimplifier(self.pts)] 334 | 335 | def line2wkt(self,pts): 336 | return u'LINESTRING %s'%pts 337 | 338 | def linemask(self,threshold): 339 | get_pts = self.get_pts 340 | pts = get_pts(self.simplifiers[0],threshold) 341 | if self.return_GDAL: 342 | return self.Geometry(self.line2wkt(pts)) 343 | else: 344 | return pts 345 | 346 | def polybuild(self): 347 | list_of_pts = self.pts 348 | result = [] 349 | for pts in list_of_pts: 350 | result.append(WKTSimplifier(pts)) 351 | self.simplifiers = result 352 | 353 | def poly2wkt(self,list_of_pts): 354 | return u'POLYGON (%s)'%','.join(list_of_pts) 355 | 356 | def polymask(self,threshold): 357 | get_pts = self.get_pts 358 | sims = self.simplifiers 359 | list_of_pts = [get_pts(sim,threshold) for sim in sims] 360 | if self.return_GDAL: 361 | return self.Geometry(self.poly2wkt(list_of_pts)) 362 | else: 363 | return array(list_of_pts) 364 | 365 | def multibuild(self): 366 | list_of_list_of_pts = self.pts 367 | result = [] 368 | for list_of_pts in list_of_list_of_pts: 369 | subresult = [] 370 | for pts in list_of_pts: 371 | subresult.append(WKTSimplifier(pts)) 372 | result.append(subresult) 373 | self.simplifiers = result 374 | 375 | def multi2wkt(self,list_of_list_of_pts): 376 | outerlist = [] 377 | for list_of_pts in list_of_list_of_pts: 378 | outerlist.append('(%s)'%','.join(list_of_pts)) 379 | return u'MULTIPOLYGON (%s)'%','.join(outerlist) 380 | 381 | def multimask(self,threshold): 382 | loflofsims = self.simplifiers 383 | result = [] 384 | get_pts = self.get_pts 385 | if self.return_GDAL: 386 | ret_func = lambda r: self.Geometry(self.multi2wkt(r)) 387 | else: 388 | ret_func = lambda r: r 389 | for list_of_simplifiers in loflofsims: 390 | subresult = [] 391 | for simplifier in list_of_simplifiers: 392 | subresult.append(get_pts(simplifier,threshold)) 393 | result.append(subresult) 394 | return ret_func(result) 395 | 396 | def notimplemented(self,n): 397 | print "This function is not yet implemented" 398 | 399 | def from_threshold(self,threshold): 400 | precision = self.precision 401 | if self.return_GDAL: 402 | self.get_pts = lambda obj,t: obj.wkt_from_threshold(t,precision) 403 | else: 404 | self.get_pts = lambda obj,t: obj.from_threshold(t) 405 | return self.maskfunc(threshold) 406 | 407 | def from_number(self,n): 408 | precision = self.precision 409 | if self.return_GDAL: 410 | self.get_pts = lambda obj,t: obj.wkt_from_number(t,precision) 411 | else: 412 | self.get_pts = lambda obj,t: obj.from_number(t) 413 | return self.maskfunc(n) 414 | 415 | def from_ratio(self,r): 416 | precision = self.precision 417 | if self.return_GDAL: 418 | self.get_pts = lambda obj,t: obj.wkt_from_ratio(t,precision) 419 | else: 420 | self.get_pts = lambda obj,t: obj.from_ratio(t) 421 | return self.maskfunc(r) 422 | 423 | 424 | def fancy_parametric(k): 425 | ''' good k's: .33,.5,.65,.7,1.3,1.4,1.9,3,4,5''' 426 | cos = np.cos 427 | sin = np.sin 428 | xt = lambda t: (k-1)*cos(t) + cos(t*(k-1)) 429 | yt = lambda t: (k-1)*sin(t) - sin(t*(k-1)) 430 | return xt,yt 431 | 432 | if __name__ == "__main__": 433 | 434 | from time import time 435 | n = 5000 436 | thetas = np.linspace(0,16*np.pi,n) 437 | xt,yt = fancy_parametric(1.4) 438 | pts = np.array([[xt(t),yt(t)] for t in thetas]) 439 | start = time() 440 | simplifier = VWSimplifier(pts) 441 | pts = simplifier.from_number(1000) 442 | end = time() 443 | print "%s vertices removed in %02f seconds"%(n-len(pts), end-start) 444 | 445 | import matplotlib 446 | matplotlib.use('AGG') 447 | import matplotlib.pyplot as plot 448 | plot.plot(pts[:,0],pts[:,1],color='r') 449 | plot.savefig('visvalingam.png') 450 | print "saved visvalingam.png" 451 | #plot.show() 452 | -------------------------------------------------------------------------------- /tests/fuzzy_circle.npy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Permafacture/Py-Visvalingam-Whyatt/5b25a31e5123275073de9160215bb04fa8fad219/tests/fuzzy_circle.npy -------------------------------------------------------------------------------- /tests/fuzzy_thresholds.npy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Permafacture/Py-Visvalingam-Whyatt/5b25a31e5123275073de9160215bb04fa8fad219/tests/fuzzy_thresholds.npy -------------------------------------------------------------------------------- /tests/tests.py: -------------------------------------------------------------------------------- 1 | ''' 2 | First is fuzzy circle. This data comes from: 3 | 4 | import numpy as np 5 | n = 5000 6 | thetas = np.linspace(0,2*np.pi,n) 7 | pts = np.array([[np.sin(x),np.cos(x)] for x in thetas]) 8 | a = pts[1:,0] - pts[:-1,0] #calc difference between points 9 | b = np.random.randint(-1,2,size=len(a)) 10 | c = (a*b) * .75 11 | pts[1:,0] += a #change x by random proportion to change at x 12 | np.save('fuzzy_circle',pts) 13 | 14 | 15 | from polysimplify import VWSimplifier 16 | new_pts = np.load('fuzzy_circle.npy') 17 | simplified = VWSimplifier(new_pts) 18 | np.save('fuzzy_thresholds',simplified.thresholds) 19 | ''' 20 | import numpy as np 21 | from polysimplify import VWSimplifier 22 | test_pts = np.load('fuzzy_circle.npy') 23 | simplified = VWSimplifier(test_pts) 24 | 25 | current_thresholds = simplified.thresholds 26 | test_thresholds = np.load('fuzzy_thresholds.npy') 27 | 28 | diff = current_thresholds - test_thresholds 29 | diff_percent = diff / test_thresholds 30 | 31 | if np.all( diff_percent[1:-1] < .00001) 32 | print "Passed fuzzy circle test" 33 | else: 34 | print "!! FAILED fuzzy circle test" 35 | --------------------------------------------------------------------------------