From 6682d20cad445b73aff1b80b483c166a53a298cb Mon Sep 17 00:00:00 2001 From: Paul Mach Date: Wed, 2 Dec 2015 16:14:33 -0800 Subject: [PATCH] Path resample to given interval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also includes “geo interval” not really sure about what that means. Hopefully the comment will help people understand the context it should be used in. --- path.go | 86 ---------------------- path_resample.go | 162 ++++++++++++++++++++++++++++++++++++++++++ path_resample_test.go | 121 +++++++++++++++++++++++++++++++ path_test.go | 63 ---------------- 4 files changed, 283 insertions(+), 149 deletions(-) create mode 100644 path_resample.go create mode 100644 path_resample_test.go diff --git a/path.go b/path.go index 36834f1..379809b 100644 --- a/path.go +++ b/path.go @@ -151,92 +151,6 @@ func (p *Path) Transform(projector Projector) *Path { return p } -// Resample converts the path into totalPoints-1 evenly spaced segments. -func (p *Path) Resample(totalPoints int) *Path { - // degenerate case - if len(p.PointSet) <= 1 { - return p - } - - if totalPoints <= 0 { - p.PointSet = make([]Point, 0) - return p - } - - // 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 p - } - - // contract to be requested length - p.PointSet = p.PointSet[:totalPoints] - return p - } - - points := make([]Point, 1, totalPoints) - points[0] = p.PointSet[0] // start stays the same - - // first distance we're looking for - step := 1 - totalDistance := 0.0 - distance := 0.0 - distances := make([]float64, len(p.PointSet)-1) - for i := 0; i < len(p.PointSet)-1; i++ { - distances[i] = p.PointSet[i].DistanceFrom(&p.PointSet[i+1]) - totalDistance += distances[i] - } - - 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 p -} - // Decode is deprecated, use NewPathFromEncoding func Decode(encoded string, factor ...int) *Path { return NewPathFromEncoding(encoded, factor...) diff --git a/path_resample.go b/path_resample.go new file mode 100644 index 0000000..2f460be --- /dev/null +++ b/path_resample.go @@ -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 +} diff --git a/path_resample_test.go b/path_resample_test.go new file mode 100644 index 0000000..5d8a727 --- /dev/null +++ b/path_resample_test.go @@ -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) + } +} diff --git a/path_test.go b/path_test.go index 687ec85..e4aca25 100644 --- a/path_test.go +++ b/path_test.go @@ -164,69 +164,6 @@ func TestPathPoints(t *testing.T) { } } -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 TestPathEncode(t *testing.T) { for loop := 0; loop < 100; loop++ { p := NewPath()