Browse Source

Refactored graph parsing into separate function

master
Patrick Gaskin 2 years ago
parent
commit
73eae262d5
Signed by: geek1011 GPG Key ID: A2FD79F68A2AB707
  1. 178
      graph.go
  2. 155
      main.go
  3. 8
      util.go

178
graph.go

@ -0,0 +1,178 @@
package main
import (
"errors"
"fmt"
"image"
"math"
"time"
)
// The following functions are calibrated for camelcamelcamel graphs with a plot
// size of 2, and marker lines of 1. The graph should also have a border enclosing
// the grid area. Dashed and dotted lines should have intervals of at most 2.
// color checks for graphs generated by camelcamelcamel. Currently only supports
// Amazon prices (green line).
var (
IsLineColor = colorMatcher(99, 168, 94)
IsMaxLineColor = colorMatcher(194, 68, 68)
IsMinLineColor = colorMatcher(119, 195, 107) // MUST be different from line color or will get problems tracing
IsGridColor = colorMatcher(215, 215, 214)
IsBgColor = colorMatcher(255, 255, 254)
)
// PriceChange represents a price change.
type PriceChange struct {
InStock bool
Date time.Time
Price float64
}
func (p PriceChange) String() string {
var is string
if p.InStock {
is = "in stock"
} else {
is = "not in stock"
}
return fmt.Sprintf("%s: %.2f (%s)", p.Date.Format("2006-01-02"), p.Price, is)
}
func (p PriceChange) CSV() string {
var os int
if !p.InStock {
os = 1
}
return fmt.Sprintf("%s,%.2f,%d", p.Date.Format("2006-01-02"), p.Price, os)
}
// ParseGraph parses a camelcamelcamel price graph (only supports single Amazon
// price line though).
func ParseGraph(img image.Image, maxPrice, minPrice float64, startDate, endDate time.Time) ([]PriceChange, error) {
graphBounds := getGraphBounds(img, IsGridColor)
if graphBounds == nil {
return nil, errors.New("could not extract graph boundaries")
}
priceExtrapolator := getPriceExtrapolator(img, *graphBounds, maxPrice, minPrice, IsMaxLineColor, IsMinLineColor)
if priceExtrapolator == nil {
return nil, errors.New("could not parse price axis for extrapolations")
}
dateExtrapolator := getDateExtrapolator(img, *graphBounds, startDate, endDate)
if dateExtrapolator == nil {
return nil, errors.New("could not parse date axis for extrapolations")
}
var changes []PriceChange
var curPrice float64
var curStock bool
err := traceLine(img, *graphBounds, IsLineColor, func(x, y int, isDotted bool) {
inStock, date, price := !isDotted, dateExtrapolator(x), priceExtrapolator(y)
if curPrice == price && curStock == inStock {
return
}
curPrice, curStock = price, inStock
changes = append(changes, PriceChange{inStock, date, price})
})
return changes, err
}
// traceLine traces the grid line.
func traceLine(img image.Image, graphBounds image.Rectangle, line colorMatcherFunc, cb func(x, y int, isDotted bool)) error {
curDotted := false
for x := graphBounds.Min.X; x < graphBounds.Max.X; x++ {
curY := -1
for y := graphBounds.Min.Y; y < graphBounds.Max.Y; y++ {
onLine := line(img.At(x, y))
if x > 2 && x < graphBounds.Max.X-2 {
if line(img.At(x-2, y)) && line(img.At(x+2, y)) {
if onLine {
curDotted = false
} else {
onLine = true
curDotted = true
}
}
}
if onLine {
curY = y
break
}
}
if curY == -1 {
if x < graphBounds.Max.X {
return fmt.Errorf("line ended early at %d", x)
}
break
}
cb(x, curY, curDotted)
}
return nil
}
// getGraphBounds returns the detected graph bounds (based on lines of the grid
// color at least 75% of the image width).
func getGraphBounds(img image.Image, fn colorMatcherFunc) *image.Rectangle {
var yTop, yBottom, xLeft, xRight int
for y := 0; y < img.Bounds().Max.Y; y++ {
if pctColor(img, fn, y, false) > 0.75 {
yTop = y
break
}
}
for y := img.Bounds().Max.Y; y > 0; y-- {
if pctColor(img, fn, y, false) > 0.75 {
yBottom = y
break
}
}
for x := 0; x < img.Bounds().Max.X; x++ {
if pctColor(img, fn, x, true) > 0.75 {
xLeft = x
break
}
}
for x := img.Bounds().Max.X; x > 0; x-- {
if pctColor(img, fn, x, true) > 0.75 {
xRight = x
break
}
}
if yTop == 0 || yBottom == 0 || xLeft == 0 || xRight == 0 {
return nil
}
r := image.Rect(xLeft, yTop, xRight, yBottom)
return &r
}
// getPriceExtrapolator gets an extrapolator to extract the price from the image
// based on a y value relative to the image (NOT the grid).
func getPriceExtrapolator(img image.Image, graphBounds image.Rectangle, maxPrice, minPrice float64, maxColor, minColor colorMatcherFunc) func(y int) float64 {
// dashed line, so threshold is 0.4; the line is on bottom of plotted line, so subtract 1.
var maxY, minY int
for y := graphBounds.Min.Y; y < graphBounds.Max.Y; y++ {
if maxY == 0 && pctColor(img, maxColor, y, false) > 0.4 {
maxY = y - 1
} else if minY == 0 && pctColor(img, minColor, y, false) > 0.4 {
minY = y - 1
}
}
if minY < maxY {
return nil
}
return func(y int) float64 {
return math.Round(extrapolate(maxY, minY, maxPrice, minPrice, y)*100) / 100
}
}
// getDateExtrapolator gets an extrapolator to extract the date from the image
// based on an x value relative to the image (NOT the grid). It allows extrapolating
// up to 100 days before or after the known range.
func getDateExtrapolator(img image.Image, gridBounds image.Rectangle, startDate, endDate time.Time) func(x int) time.Time {
startIndex, dates, endIndex := getDaysBetween(startDate, endDate, 100)
return func(x int) time.Time {
return dates[int(math.Round(extrapolate(gridBounds.Min.X+1, gridBounds.Max.X, float64(startIndex), float64(endIndex), x)))]
}
}

155
main.go

@ -2,165 +2,24 @@ package main
import (
"fmt"
"image"
"math"
"time"
)
func main() {
// color checks for graphs generated by camelcamelcamel. Currently only supports
// Amazon prices (green line).
var (
isLineColor = colorMatcher(99, 168, 94)
isMaxLineColor = colorMatcher(194, 68, 68)
isMinLineColor = colorMatcher(119, 195, 107) // MUST be different from line color or will get problems tracing
isGridColor = colorMatcher(215, 215, 214)
isBgColor = colorMatcher(255, 255, 254)
)
maxPrice, minPrice := 59.99, 9.99
startDate, endDate := time.Date(2015, 10, 29, 0, 0, 0, 0, time.Local), time.Date(2019, 05, 15, 0, 0, 0, 0, time.Local)
_, _, _, _, _, _, _, _, _ = isLineColor, isMaxLineColor, isMinLineColor, isGridColor, isBgColor, maxPrice, minPrice, startDate, endDate
img := must(loadPNG("graph.png")).(image.Image)
graphBounds := *getGraphBounds(img, isGridColor)
priceExtrapolator := getPriceExtrapolator(img, graphBounds, maxPrice, minPrice, isMaxLineColor, isMinLineColor)
dateExtrapolator := getDateExtrapolator(img, graphBounds, startDate, endDate)
fmt.Println(graphBounds) // (54,16)-(15945,7951)
fmt.Println(priceExtrapolator(3127)) // 39.99
fmt.Println(priceExtrapolator(16)) // 59.99
fmt.Println(priceExtrapolator(4682)) // 30.00
fmt.Println(priceExtrapolator(6862)) // 15.99
fmt.Println(priceExtrapolator(7795)) // 9.99
fmt.Println(dateExtrapolator(55)) // Oct 29, 2015
fmt.Println(dateExtrapolator(1636)) // Mar 6, 2016
fmt.Println(dateExtrapolator(3230)) // Jul 14, 2016
fmt.Println(dateExtrapolator(4824)) // Nov 20, 2016
fmt.Println(dateExtrapolator(6418)) // Mar 30, 2017
fmt.Println(dateExtrapolator(14387)) // Jan 8, 2019
var curPrice float64
var curStock bool
if err := traceLine(img, graphBounds, isLineColor, func(x, y int, isDotted bool) {
inStock, date, price := !isDotted, dateExtrapolator(x), priceExtrapolator(y)
if curPrice == price && curStock == inStock {
return
}
curPrice, curStock = price, inStock
is := 1
if inStock {
is = 0
}
fmt.Printf("%d,%s,%.2f\n", is, date.Format("2006-01-02"), curPrice)
}); err != nil {
img, err := loadPNG("graph.png")
if err != nil {
panic(err)
}
}
// The following functions are calibrated for camelcamelcamel graphs with a plot
// size of 2, and marker lines of 1. The graph should also have a border enclosing
// the grid area. Dashed and dotted lines should have intervals of at most 2.
// traceLine traces the grid line.
func traceLine(img image.Image, graphBounds image.Rectangle, line colorMatcherFunc, cb func(x, y int, isDotted bool)) error {
curDotted := false
for x := graphBounds.Min.X; x < graphBounds.Max.X; x++ {
curY := -1
for y := graphBounds.Min.Y; y < graphBounds.Max.Y; y++ {
onLine := line(img.At(x, y))
if x > 2 && x < graphBounds.Max.X-2 {
if line(img.At(x-2, y)) && line(img.At(x+2, y)) {
if onLine {
curDotted = false
} else {
onLine = true
curDotted = true
}
}
}
if onLine {
curY = y
break
}
}
if curY == -1 {
if x < graphBounds.Max.X {
return fmt.Errorf("line ended early at %d", x)
}
break
}
cb(x, curY, curDotted)
}
return nil
}
// getGraphBounds returns the detected graph bounds (based on lines of the grid
// color at least 75% of the image width).
func getGraphBounds(img image.Image, fn colorMatcherFunc) *image.Rectangle {
var yTop, yBottom, xLeft, xRight int
for y := 0; y < img.Bounds().Max.Y; y++ {
if pctColor(img, fn, y, false) > 0.75 {
yTop = y
break
}
}
for y := img.Bounds().Max.Y; y > 0; y-- {
if pctColor(img, fn, y, false) > 0.75 {
yBottom = y
break
}
}
for x := 0; x < img.Bounds().Max.X; x++ {
if pctColor(img, fn, x, true) > 0.75 {
xLeft = x
break
}
}
for x := img.Bounds().Max.X; x > 0; x-- {
if pctColor(img, fn, x, true) > 0.75 {
xRight = x
break
}
}
if yTop == 0 || yBottom == 0 || xLeft == 0 || xRight == 0 {
return nil
}
r := image.Rect(xLeft, yTop, xRight, yBottom)
return &r
}
// getPriceExtrapolator gets an extrapolator to extract the price from the image
// based on a y value relative to the image (NOT the grid).
func getPriceExtrapolator(img image.Image, graphBounds image.Rectangle, maxPrice, minPrice float64, maxColor, minColor colorMatcherFunc) func(y int) float64 {
// dashed line, so threshold is 0.4; the line is on bottom of plotted line, so subtract 1.
var maxY, minY int
for y := graphBounds.Min.Y; y < graphBounds.Max.Y; y++ {
if maxY == 0 && pctColor(img, maxColor, y, false) > 0.4 {
maxY = y - 1
} else if minY == 0 && pctColor(img, minColor, y, false) > 0.4 {
minY = y - 1
}
}
if minY < maxY {
return nil
}
return func(y int) float64 {
return math.Round(extrapolate(maxY, minY, maxPrice, minPrice, y)*100) / 100
parsed, err := ParseGraph(img, maxPrice, minPrice, startDate, endDate)
if err != nil {
panic(err)
}
}
// getDateExtrapolator gets an extrapolator to extract the date from the image
// based on an x value relative to the image (NOT the grid). It allows extrapolating
// up to 100 days before or after the known range.
func getDateExtrapolator(img image.Image, gridBounds image.Rectangle, startDate, endDate time.Time) func(x int) time.Time {
startIndex, dates, endIndex := getDaysBetween(startDate, endDate, 100)
return func(x int) time.Time {
return dates[int(math.Round(extrapolate(gridBounds.Min.X+1, gridBounds.Max.X, float64(startIndex), float64(endIndex), x)))]
for _, p := range parsed {
fmt.Println(p.String())
}
}

8
util.go

@ -57,11 +57,3 @@ func loadPNG(path string) (image.Image, error) {
defer f.Close()
return png.Decode(f)
}
// must panics if err is not nil. This is only to simplify testing during development.
func must(v interface{}, err error) interface{} {
if err != nil {
panic(err)
}
return v
}