Parses graphs and other data from camelcamelcamel.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
This repo is archived. You can view files and clone it, but cannot push or open issues/pull-requests.

178 lines
5.2 KiB

package graph
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)
}
// Parse parses a camelcamelcamel price graph (only supports single Amazon
// price line though).
func Parse(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)))]
}
}