-
Notifications
You must be signed in to change notification settings - Fork 55
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #31 from paulmach/pm/resample-interval
Path resample to given interval
- Loading branch information
Showing
4 changed files
with
283 additions
and
149 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 |
---|---|---|
@@ -0,0 +1,162 @@ | ||
package geo | ||
|
||
// Resample converts the path into totalPoints-1 evenly spaced segments. | ||
// Assumes euclidean geometry. | ||
func (p *Path) Resample(totalPoints int) *Path { | ||
if totalPoints <= 0 { | ||
p.PointSet = make([]Point, 0) | ||
return p | ||
} | ||
|
||
if p.resampleEdgeCases(totalPoints) { | ||
return p | ||
} | ||
|
||
// precomputes the total distance and intermediate distances | ||
total, dists := precomputeDistances(p.PointSet) | ||
p.resample(dists, total, totalPoints) | ||
return p | ||
} | ||
|
||
// ResampleWithInterval coverts the path into evenly spaced points of | ||
// about the given distance. The total distance is computed using euclidean | ||
// geometry and then divided by the given distance to get the number of segments. | ||
func (p *Path) ResampleWithInterval(dist float64) *Path { | ||
if dist <= 0 { | ||
p.PointSet = make([]Point, 0) | ||
return p | ||
} | ||
|
||
// precomputes the total distance and intermediate distances | ||
total, dists := precomputeDistances(p.PointSet) | ||
|
||
totalPoints := int(total/dist) + 1 | ||
if p.resampleEdgeCases(totalPoints) { | ||
return p | ||
} | ||
|
||
p.resample(dists, total, totalPoints) | ||
return p | ||
} | ||
|
||
// ResampleWithGeoInterval converts the path into about evenly spaced points of | ||
// about the given distance. The total distance is computed using spherical (lng/lat) geometry | ||
// and divided by the given distance. The new points are chosen by linearly interpolating | ||
// between two given points. This may not make sense in some contexts, especially if | ||
// the path covers a large range of latitude. | ||
func (p *Path) ResampleWithGeoInterval(meters float64) *Path { | ||
if meters <= 0 { | ||
p.PointSet = make([]Point, 0) | ||
return p | ||
} | ||
|
||
// precomputes the total geo distance and intermediate distances | ||
totalDistance := 0.0 | ||
distances := make([]float64, len(p.PointSet)-1) | ||
for i := 0; i < len(p.PointSet)-1; i++ { | ||
distances[i] = p.PointSet[i].GeoDistanceFrom(&p.PointSet[i+1]) | ||
totalDistance += distances[i] | ||
} | ||
|
||
totalPoints := int(totalDistance/meters) + 1 | ||
if p.resampleEdgeCases(totalPoints) { | ||
return p | ||
} | ||
|
||
p.resample(distances, totalDistance, totalPoints) | ||
return p | ||
} | ||
|
||
func (p *Path) resample(distances []float64, totalDistance float64, totalPoints int) { | ||
points := make([]Point, 1, totalPoints) | ||
points[0] = p.PointSet[0] // start stays the same | ||
|
||
step := 1 | ||
distance := 0.0 | ||
|
||
currentDistance := totalDistance / float64(totalPoints-1) | ||
currentLine := &Line{} // declare here and update has nice performance benefits | ||
for i := 0; i < len(p.PointSet)-1; i++ { | ||
currentLine.a = p.PointSet[i] | ||
currentLine.b = p.PointSet[i+1] | ||
|
||
currentLineDistance := distances[i] | ||
nextDistance := distance + currentLineDistance | ||
|
||
for currentDistance <= nextDistance { | ||
// need to add a point | ||
percent := (currentDistance - distance) / currentLineDistance | ||
points = append(points, Point{ | ||
currentLine.a[0] + percent*(currentLine.b[0]-currentLine.a[0]), | ||
currentLine.a[1] + percent*(currentLine.b[1]-currentLine.a[1]), | ||
}) | ||
|
||
// move to the next distance we want | ||
step++ | ||
currentDistance = totalDistance * float64(step) / float64(totalPoints-1) | ||
if step == totalPoints-1 { // weird round off error on my machine | ||
currentDistance = totalDistance | ||
} | ||
} | ||
|
||
// past the current point in the original line, so move to the next one | ||
distance = nextDistance | ||
} | ||
|
||
// end stays the same, to handle round off errors | ||
if totalPoints != 1 { // for 1, we want the first point | ||
points[totalPoints-1] = p.PointSet[len(p.PointSet)-1] | ||
} | ||
|
||
(&p.PointSet).SetPoints(points) | ||
return | ||
} | ||
|
||
// resampleEdgeCases is used to handle edge case for | ||
// resampling like not enough points and the path is all the same point. | ||
// will return nil if there are no edge cases. If return true if | ||
// one of these edge cases was found and handled. | ||
func (p *Path) resampleEdgeCases(totalPoints int) bool { | ||
// degenerate case | ||
if len(p.PointSet) <= 1 { | ||
return true | ||
} | ||
|
||
// if all the points are the same, treat as special case. | ||
equal := true | ||
for _, point := range p.PointSet { | ||
if !p.PointSet[0].Equals(&point) { | ||
equal = false | ||
break | ||
} | ||
} | ||
|
||
if equal { | ||
if totalPoints > p.Length() { | ||
// extend to be requested length | ||
for p.Length() != totalPoints { | ||
p.PointSet = append(p.PointSet, p.PointSet[0]) | ||
} | ||
|
||
return true | ||
} | ||
|
||
// contract to be requested length | ||
p.PointSet = p.PointSet[:totalPoints] | ||
return true | ||
} | ||
|
||
return false | ||
} | ||
|
||
// precomputeDistances precomputes the total distance and intermediate distances. | ||
func precomputeDistances(p PointSet) (float64, []float64) { | ||
total := 0.0 | ||
dists := make([]float64, len(p)-1) | ||
for i := 0; i < len(p)-1; i++ { | ||
dists[i] = p[i].DistanceFrom(&p[i+1]) | ||
total += dists[i] | ||
} | ||
|
||
return total, dists | ||
} |
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,121 @@ | ||
package geo | ||
|
||
import "testing" | ||
|
||
func TestPathResample(t *testing.T) { | ||
p := NewPath() | ||
p.Resample(10) // should not panic | ||
|
||
p.Push(NewPoint(0, 0)).Resample(10) // should not panic | ||
p.Push(NewPoint(1.5, 1.5)) | ||
p.Push(NewPoint(2, 2)) | ||
|
||
// resample to 0? | ||
result := p.Clone().Resample(0) | ||
if result.Length() != 0 { | ||
t.Error("path, resample down to zero should be empty line") | ||
} | ||
|
||
// resample to 1 | ||
result = p.Clone().Resample(1) | ||
answer := NewPath().Push(NewPoint(0, 0)) | ||
if !result.Equals(answer) { | ||
t.Error("path, resample down to 1 should be first point") | ||
} | ||
|
||
result = p.Clone().Resample(2) | ||
answer = NewPath().Push(NewPoint(0, 0)).Push(NewPoint(2, 2)) | ||
if !result.Equals(answer) { | ||
t.Error("path, resample downsampling") | ||
} | ||
|
||
result = p.Clone().Resample(5) | ||
answer = NewPath() | ||
answer.Push(NewPoint(0, 0)).Push(NewPoint(0.5, 0.5)) | ||
answer.Push(NewPoint(1, 1)).Push(NewPoint(1.5, 1.5)) | ||
answer.Push(NewPoint(2, 2)) | ||
if !result.Equals(answer) { | ||
t.Error("path, resample upsampling") | ||
t.Error(result) | ||
t.Error(answer) | ||
} | ||
|
||
// round off error case, triggered on my laptop | ||
p1 := NewPath().Push(NewPoint(-88.145243, 42.321059)).Push(NewPoint(-88.145232, 42.325902)) | ||
p1.Resample(109) | ||
if p1.Length() != 109 { | ||
t.Errorf("path, resample incorrect length, expected 109, got %d", p1.Length()) | ||
} | ||
|
||
// duplicate points | ||
p = NewPath() | ||
p.Push(NewPoint(1, 0)) | ||
p.Push(NewPoint(1, 0)) | ||
p.Push(NewPoint(1, 0)) | ||
|
||
p.Resample(10) | ||
if l := p.Length(); l != 10 { | ||
t.Errorf("path, resample length incorrect, got %d", l) | ||
} | ||
|
||
for i := 0; i < p.Length(); i++ { | ||
if !p.GetAt(i).Equals(NewPoint(1, 0)) { | ||
t.Errorf("path, resample not correct point, got %v", p.GetAt(i)) | ||
} | ||
} | ||
} | ||
|
||
func TestPathResampleWithInterval(t *testing.T) { | ||
p := NewPath() | ||
p.Push(NewPoint(0, 0)) | ||
p.Push(NewPoint(0, 10)) | ||
|
||
p.ResampleWithInterval(5.0) | ||
if l := p.Length(); l != 3 { | ||
t.Errorf("incorrect resample, got %v", l) | ||
} | ||
|
||
if v := p.GetAt(1); !v.Equals(NewPoint(0, 5.0)) { | ||
t.Errorf("incorrect point, got %v", v) | ||
} | ||
} | ||
|
||
func TestPathResampleWithGeoInterval(t *testing.T) { | ||
p := NewPath() | ||
p.Push(NewPoint(0, 0)) | ||
p.Push(NewPoint(0, 10)) | ||
|
||
d := p.GeoDistance() / 2 | ||
p.ResampleWithGeoInterval(d) | ||
if l := p.Length(); l != 3 { | ||
t.Errorf("incorrect resample, got %v", l) | ||
} | ||
|
||
if v := p.GetAt(1); !v.Equals(NewPoint(0, 5.0)) { | ||
t.Errorf("incorrect point, got %v", v) | ||
} | ||
} | ||
|
||
func TestPathResampleEdgeCases(t *testing.T) { | ||
p := NewPath() | ||
p.Push(NewPoint(0, 0)) | ||
|
||
if !p.resampleEdgeCases(10) { | ||
t.Errorf("should return false") | ||
} | ||
|
||
// duplicate points | ||
p.Push(NewPoint(0, 0)) | ||
if !p.resampleEdgeCases(10) { | ||
t.Errorf("should return true") | ||
} | ||
|
||
if l := p.Length(); l != 10 { | ||
t.Errorf("should reset to suggested points, got %v", l) | ||
} | ||
|
||
p.resampleEdgeCases(5) | ||
if l := p.Length(); l != 5 { | ||
t.Errorf("should shorten if necessary, got %v", l) | ||
} | ||
} |
Oops, something went wrong.