From bd7eb246d5d0fc0b1663d70d9544cf8c46126527 Mon Sep 17 00:00:00 2001 From: Ray Miller Date: Thu, 19 Jan 2023 16:21:32 +0000 Subject: [PATCH] Improvements to duplicate suppression, cafe stop search, and configurability. When suppressing duplicate cafes and place names, look back a certain distance along the route rather than just the previous point of interest. When searching for cafes, use SearchIntersect() to return all entries in the bounding rectangle, not just the nearest. Remove (most) hard-coded constants and allow these to be overriden by options to the NewGPXSummarizer() constructor. --- cmd/analyze-gpx/main.go | 17 +++-- pkg/cafes/common.go | 2 +- pkg/placenames/summarize.go | 135 ++++++++++++++++++++++++++++-------- 3 files changed, 118 insertions(+), 36 deletions(-) diff --git a/cmd/analyze-gpx/main.go b/cmd/analyze-gpx/main.go index e88cdb8..fa1c0bc 100644 --- a/cmd/analyze-gpx/main.go +++ b/cmd/analyze-gpx/main.go @@ -19,8 +19,11 @@ import ( func main() { log.SetFlags(0) stopNames := flag.String("stops", "", "Source for refreshment stops") - minDist := flag.Float64("min-dist", 0.2, "Minimum distance (km) between points of interest") - minSettlement := flag.String("min-settlement", "Other Settlement", "Exclude populated places smaller than this (City, Town, Village, Hamlet, Other Settlement)") + stopRect := flag.Float64("sr", placenames.DefaultGPXSummarizerConfig.CoffeeStopSearchRectangleSize, "Size (m) of the rectangle we search for coffee stops near the route") + stopDupDist := flag.Float64("sdd", placenames.DefaultGPXSummarizerConfig.CoffeeStopDuplicateDistance, "Suppress recurrences of coffee stops within this distance (km)") + dupDist := flag.Float64("dd", placenames.DefaultGPXSummarizerConfig.PointOfInterestDuplicateDistance, "Suppress recurrences of points of interest within this distance (km)") + minDist := flag.Float64("md", placenames.DefaultGPXSummarizerConfig.PointOfInterestMinimumDistance, "Minimum distance (km) between points of interest") + minSettlement := flag.String("ms", "Other Settlement", "Exclude populated places smaller than this (City, Town, Village, Hamlet, Other Settlement)") flag.Parse() if flag.NArg() != 1 { log.Fatal("Usage: %s [--stops=ctccambridge|cyclingmaps] [--min-dist X] [--min-settlement S] GPX_FILE_OR_DIRECTORY") @@ -38,12 +41,16 @@ func main() { log.Fatal(err) } } - gs, err := placenames.NewGPXSummarizer() + gs, err := placenames.NewGPXSummarizer( + placenames.WithMinimumSettlement(*minSettlement), + placenames.WithPointOfInterestMinimumDistance(*minDist), + placenames.WithPointOfInterestDuplicateDistance(*dupDist), + placenames.WithCoffeeStopSearchRectangleSize(*stopRect), + placenames.WithCoffeeStopDuplicateDistance(*stopDupDist), + ) if err != nil { log.Fatal(err) } - gs.SetMinDistance(*minDist) - gs.SetMinSettlement(*minSettlement) if info.IsDir() { err = summarizeDirectory(gs, stops, inFile) } else { diff --git a/pkg/cafes/common.go b/pkg/cafes/common.go index d059b09..b216e7e 100644 --- a/pkg/cafes/common.go +++ b/pkg/cafes/common.go @@ -10,7 +10,7 @@ import ( ) // Size (in metres) of the bounding box around a stop -const stopRectangleSize = 500 +const stopRectangleSize = 50 type RefreshmentStop struct { Name string diff --git a/pkg/placenames/summarize.go b/pkg/placenames/summarize.go index b436237..cfb770d 100644 --- a/pkg/placenames/summarize.go +++ b/pkg/placenames/summarize.go @@ -9,9 +9,8 @@ import ( "github.com/dhconnelly/rtreego" "github.com/fofanov/go-osgb" - "github.com/twpayne/go-gpx" - "github.com/ray1729/gpx-utils/pkg/cafes" + "github.com/twpayne/go-gpx" ) var populatedPlaceRank = map[string]int{ @@ -22,14 +21,80 @@ var populatedPlaceRank = map[string]int{ "Other Settlement": 1, } -type GPXSummarizer struct { - poi *rtreego.Rtree - trans osgb.CoordinateTransformer - minDist float64 - minSettlementRank int +// GPXSummarizerConfig allows override of defaults used by the search algorithm. +type GPXSummarizerConfig struct { + CoffeeStopSearchRectangleSize float64 + CoffeeStopDuplicateDistance float64 + PointOfInterestDuplicateDistance float64 + PointOfInterestMinimumDistance float64 + MinimumSettlementRank int } -func NewGPXSummarizer() (*GPXSummarizer, error) { +var DefaultGPXSummarizerConfig = GPXSummarizerConfig{ + CoffeeStopSearchRectangleSize: 500.0, // m + CoffeeStopDuplicateDistance: 2.0, // km + PointOfInterestDuplicateDistance: 1.0, // km + PointOfInterestMinimumDistance: 0.2, // km + MinimumSettlementRank: 1, // "Other Settlement" +} + +type Option func(*GPXSummarizerConfig) + +// WithCoffeeStopSearchRectangleSize overrides the size (in metres) of the rectangle searched +// for coffee stops near the route. Default 500m. +func WithCoffeeStopSearchRectangleSize(d float64) Option { + return func(c *GPXSummarizerConfig) { + c.CoffeeStopSearchRectangleSize = d + } +} + +// WithCoffeeStopDuplicateDistance overrides the distance (in kilometers) we look back along the +// route when suppressing duplicate coffee stop entries. This should be at least twice the +// CoffeeStopSearchRectangleSize. Default 2km. +func WithCoffeeStopDuplicateDistance(d float64) Option { + return func(c *GPXSummarizerConfig) { + c.CoffeeStopDuplicateDistance = d + } +} + +// WithPointOfInterestDuplicateDistance overrides the distance (in km) we look back along +// the route when suppressing duplicate points of interest. +func WithPointOfInterestDuplicateDistance(d float64) Option { + return func(c *GPXSummarizerConfig) { + c.PointOfInterestDuplicateDistance = d + } +} + +// WithPointOfInterestMinimumDistance overrides the minimum distance (in km) between points +// of interest (if two POI appear within this distance, the second one is suppressed). Default +// 0km (no suppression). +func WithPointOfInterestMinimumDistance(d float64) Option { + return func(c *GPXSummarizerConfig) { + c.PointOfInterestMinimumDistance = d + } +} + +func WithMinimumSettlement(s string) Option { + rank, ok := populatedPlaceRank[s] + if !ok { + panic(fmt.Sprintf("invalid settlement type: %s", s)) + } + return func(c *GPXSummarizerConfig) { + c.MinimumSettlementRank = rank + } +} + +type GPXSummarizer struct { + poi *rtreego.Rtree + trans osgb.CoordinateTransformer + conf GPXSummarizerConfig +} + +func NewGPXSummarizer(opts ...Option) (*GPXSummarizer, error) { + conf := DefaultGPXSummarizerConfig + for _, f := range opts { + f(&conf) + } trans, err := osgb.NewOSTN15Transformer() if err != nil { return nil, err @@ -38,15 +103,7 @@ func NewGPXSummarizer() (*GPXSummarizer, error) { if err != nil { return nil, err } - return &GPXSummarizer{poi: rt, trans: trans, minDist: 0.2, minSettlementRank: 1}, nil -} - -func (gs *GPXSummarizer) SetMinSettlement(t string) { - gs.minSettlementRank = populatedPlaceRank[t] -} - -func (gs *GPXSummarizer) SetMinDistance(d float64) { - gs.minDist = d + return &GPXSummarizer{poi: rt, trans: trans, conf: conf}, nil } func distance(p1, p2 rtreego.Point) float64 { @@ -108,7 +165,6 @@ func (gs *GPXSummarizer) SummarizeTrack(r io.Reader, stops *rtreego.Rtree) (*Tra var prevPlace string var prevPlacePoint rtreego.Point var prevPoint rtreego.Point - var prevStop *cafes.RefreshmentStop var start rtreego.Point var dN, dE float64 @@ -141,25 +197,44 @@ func (gs *GPXSummarizer) SummarizeTrack(r io.Reader, stops *rtreego.Rtree) (*Tra s.Distance += distance(thisPoint, prevPoint) dE += thisPoint[0] - start[0] dN += thisPoint[1] - start[1] - if nn.Contains(thisPoint) { + if nn.Contains(thisPoint) && populatedPlaceRank[nn.Type] >= gs.conf.MinimumSettlementRank { s.Counties[nn.County]++ - if nn.Name != prevPlace && - distance(thisPoint, prevPlacePoint) > gs.minDist && - populatedPlaceRank[nn.Type] >= gs.minSettlementRank { + seenRecently := false + for i := len(s.PointsOfInterest) - 1; i >= 0; i-- { + if i < len(s.PointsOfInterest)-1 && s.Distance-s.PointsOfInterest[i].Distance > gs.conf.PointOfInterestDuplicateDistance { + break + } + if nn.Name == s.PointsOfInterest[i].Name { + seenRecently = true + break + } + } + if !seenRecently && distance(thisPoint, prevPlacePoint) > gs.conf.PointOfInterestMinimumDistance { s.PointsOfInterest = append(s.PointsOfInterest, POI{Name: nn.Name, Type: nn.Type, Distance: s.Distance}) prevPlace = nn.Name prevPlacePoint = thisPoint } } if stops != nil { - stop, ok := stops.NearestNeighbor(thisPoint).(*cafes.RefreshmentStop) - if ok && stop.Contains(thisPoint) && (prevStop == nil || stop.Name != prevStop.Name) { - s.RefreshmentStops = append(s.RefreshmentStops, RefreshmentStop{ - Name: stop.Name, - Url: stop.Url, - Distance: s.Distance, - }) - prevStop = stop + for _, nearbyStop := range stops.SearchIntersect(thisPoint.ToRect(gs.conf.CoffeeStopSearchRectangleSize)) { + stop := nearbyStop.(*cafes.RefreshmentStop) + seenRecently := false + for i := len(s.RefreshmentStops) - 1; i >= 0; i-- { + if i < len(s.RefreshmentStops)-1 && s.Distance-s.RefreshmentStops[i].Distance > gs.conf.CoffeeStopDuplicateDistance { + break + } + if s.RefreshmentStops[i].Name == stop.Name { + seenRecently = true + break + } + } + if !seenRecently { + s.RefreshmentStops = append(s.RefreshmentStops, RefreshmentStop{ + Name: stop.Name, + Url: stop.Url, + Distance: s.Distance, + }) + } } } prevPoint = thisPoint