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.
This commit is contained in:
Ray Miller 2023-01-19 16:21:32 +00:00
parent 4de001c867
commit bd7eb246d5
3 changed files with 118 additions and 36 deletions

View file

@ -19,8 +19,11 @@ import (
func main() { func main() {
log.SetFlags(0) log.SetFlags(0)
stopNames := flag.String("stops", "", "Source for refreshment stops") stopNames := flag.String("stops", "", "Source for refreshment stops")
minDist := flag.Float64("min-dist", 0.2, "Minimum distance (km) between points of interest") stopRect := flag.Float64("sr", placenames.DefaultGPXSummarizerConfig.CoffeeStopSearchRectangleSize, "Size (m) of the rectangle we search for coffee stops near the route")
minSettlement := flag.String("min-settlement", "Other Settlement", "Exclude populated places smaller than this (City, Town, Village, Hamlet, Other Settlement)") 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() flag.Parse()
if flag.NArg() != 1 { if flag.NArg() != 1 {
log.Fatal("Usage: %s [--stops=ctccambridge|cyclingmaps] [--min-dist X] [--min-settlement S] GPX_FILE_OR_DIRECTORY") 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) 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 { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
gs.SetMinDistance(*minDist)
gs.SetMinSettlement(*minSettlement)
if info.IsDir() { if info.IsDir() {
err = summarizeDirectory(gs, stops, inFile) err = summarizeDirectory(gs, stops, inFile)
} else { } else {

View file

@ -10,7 +10,7 @@ import (
) )
// Size (in metres) of the bounding box around a stop // Size (in metres) of the bounding box around a stop
const stopRectangleSize = 500 const stopRectangleSize = 50
type RefreshmentStop struct { type RefreshmentStop struct {
Name string Name string

View file

@ -9,9 +9,8 @@ import (
"github.com/dhconnelly/rtreego" "github.com/dhconnelly/rtreego"
"github.com/fofanov/go-osgb" "github.com/fofanov/go-osgb"
"github.com/twpayne/go-gpx"
"github.com/ray1729/gpx-utils/pkg/cafes" "github.com/ray1729/gpx-utils/pkg/cafes"
"github.com/twpayne/go-gpx"
) )
var populatedPlaceRank = map[string]int{ var populatedPlaceRank = map[string]int{
@ -22,14 +21,80 @@ var populatedPlaceRank = map[string]int{
"Other Settlement": 1, "Other Settlement": 1,
} }
// GPXSummarizerConfig allows override of defaults used by the search algorithm.
type GPXSummarizerConfig struct {
CoffeeStopSearchRectangleSize float64
CoffeeStopDuplicateDistance float64
PointOfInterestDuplicateDistance float64
PointOfInterestMinimumDistance float64
MinimumSettlementRank int
}
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 { type GPXSummarizer struct {
poi *rtreego.Rtree poi *rtreego.Rtree
trans osgb.CoordinateTransformer trans osgb.CoordinateTransformer
minDist float64 conf GPXSummarizerConfig
minSettlementRank int
} }
func NewGPXSummarizer() (*GPXSummarizer, error) { func NewGPXSummarizer(opts ...Option) (*GPXSummarizer, error) {
conf := DefaultGPXSummarizerConfig
for _, f := range opts {
f(&conf)
}
trans, err := osgb.NewOSTN15Transformer() trans, err := osgb.NewOSTN15Transformer()
if err != nil { if err != nil {
return nil, err return nil, err
@ -38,15 +103,7 @@ func NewGPXSummarizer() (*GPXSummarizer, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &GPXSummarizer{poi: rt, trans: trans, minDist: 0.2, minSettlementRank: 1}, nil return &GPXSummarizer{poi: rt, trans: trans, conf: conf}, nil
}
func (gs *GPXSummarizer) SetMinSettlement(t string) {
gs.minSettlementRank = populatedPlaceRank[t]
}
func (gs *GPXSummarizer) SetMinDistance(d float64) {
gs.minDist = d
} }
func distance(p1, p2 rtreego.Point) float64 { 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 prevPlace string
var prevPlacePoint rtreego.Point var prevPlacePoint rtreego.Point
var prevPoint rtreego.Point var prevPoint rtreego.Point
var prevStop *cafes.RefreshmentStop
var start rtreego.Point var start rtreego.Point
var dN, dE float64 var dN, dE float64
@ -141,25 +197,44 @@ func (gs *GPXSummarizer) SummarizeTrack(r io.Reader, stops *rtreego.Rtree) (*Tra
s.Distance += distance(thisPoint, prevPoint) s.Distance += distance(thisPoint, prevPoint)
dE += thisPoint[0] - start[0] dE += thisPoint[0] - start[0]
dN += thisPoint[1] - start[1] dN += thisPoint[1] - start[1]
if nn.Contains(thisPoint) { if nn.Contains(thisPoint) && populatedPlaceRank[nn.Type] >= gs.conf.MinimumSettlementRank {
s.Counties[nn.County]++ s.Counties[nn.County]++
if nn.Name != prevPlace && seenRecently := false
distance(thisPoint, prevPlacePoint) > gs.minDist && for i := len(s.PointsOfInterest) - 1; i >= 0; i-- {
populatedPlaceRank[nn.Type] >= gs.minSettlementRank { 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}) s.PointsOfInterest = append(s.PointsOfInterest, POI{Name: nn.Name, Type: nn.Type, Distance: s.Distance})
prevPlace = nn.Name prevPlace = nn.Name
prevPlacePoint = thisPoint prevPlacePoint = thisPoint
} }
} }
if stops != nil { if stops != nil {
stop, ok := stops.NearestNeighbor(thisPoint).(*cafes.RefreshmentStop) for _, nearbyStop := range stops.SearchIntersect(thisPoint.ToRect(gs.conf.CoffeeStopSearchRectangleSize)) {
if ok && stop.Contains(thisPoint) && (prevStop == nil || stop.Name != prevStop.Name) { 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{ s.RefreshmentStops = append(s.RefreshmentStops, RefreshmentStop{
Name: stop.Name, Name: stop.Name,
Url: stop.Url, Url: stop.Url,
Distance: s.Distance, Distance: s.Distance,
}) })
prevStop = stop }
} }
} }
prevPoint = thisPoint prevPoint = thisPoint