package border import ( "image" "strings" log "github.com/sirupsen/logrus" ) const ( Outer = 0 Hole = 1 ) // Contour represents a single contour/border extracted from an image. // It also tracks its parents and children. type Contour struct { // Points making up the contour Points []image.Point Id int // Outer or Hole. BorderType int // Id of parent ParentId int // ParentCollision indicates if colliding with parent. Just an optimisation for quick removal later on. ParentCollision bool // Parent links to contours parent Parent *Contour // Children links to contours children Children []*Contour // ConflictingContours is a map of contours that we KNOW we conflict with. This may be the parent or other // siblings ConflictingContours map[int]bool // hate to use maps here... but want uniqueness // usable or not. Not filtering out but marking that we may not use it. (say if we're conflicting with another contour) Usable bool } // NewContour create new contour func NewContour(id int) *Contour { c := Contour{} c.Id = id c.BorderType = Hole c.ConflictingContours = make(map[int]bool) c.Usable = true return &c } // GetAllPoints returns all points in the contour and all children. func (c *Contour) GetAllPoints() []image.Point { var allPoints []image.Point for _, p := range c.Points { allPoints = append(allPoints, p) } for _, ch := range c.Children { points := ch.GetAllPoints() allPoints = append(allPoints, points...) } return allPoints } // ContourStats generates writes the stats to a log about the contour and all children. // Primarily used for debugging func ContourStats(c *Contour, offset int) { if len(c.Points) > 0 { pad := strings.Repeat(" ", offset) log.Debugf("%s%d : len %d : no kids %d : no col %d : col with parent %+v\n", pad, c.Id, len(c.Points), len(c.Children), len(c.ConflictingContours), c.ParentCollision) } for _, ch := range c.Children { ContourStats(ch, offset+2) } } // ContourStatsWithCollisions generates writes to stdout stats about the contour and all children that have collisions // Primarily used for debugging. func ContourStatsWithCollisions(c *Contour, offset int) { if len(c.Points) > 0 { if len(c.ConflictingContours) > 0 { pad := strings.Repeat(" ", offset) log.Debugf("%s%d : len %d : no kids %d : no col %d : col with parent %+v\n", pad, c.Id, len(c.Points), len(c.Children), len(c.ConflictingContours), c.ParentCollision) } } for _, ch := range c.Children { ContourStatsWithCollisions(ch, offset+2) } }
package border import ( "errors" "image" "github.com/kpfaulkner/borders/common" log "github.com/sirupsen/logrus" ) var ( // dirDelta determines which direction will we move based on the direction (0-7) index dirDelta = []image.Point{{0, -1}, {1, -1}, {1, 0}, {1, 1}, {0, 1}, {-1, 1}, {-1, 0}, {-1, -1}} ) // clockwise determines direction if we have 'dir' and turn clockwise func clockwise(dir int) int { return (dir + 1) % 8 } // counterClockwise determines direction if we have 'dir' and turn counterclockwise func counterClockwise(dir int) int { return (dir + 7) % 8 } // move moves the current point (pixel) in the direction 'dir' func move(pixel image.Point, img *common.SuzukiImage, dir int) image.Point { newP := pixel.Add(dirDelta[dir]) width := img.Width height := img.Height if (0 < newP.Y && newP.Y < height) && (0 < newP.X && newP.X < width) { if img.Get(newP) != 0 { return newP } } return image.Point{0, 0} } // calcDir returns index of dirDelta that matches direction taken. func calcDir(from image.Point, to image.Point) (int, error) { delta := to.Sub(from) for i, d := range dirDelta { if d.X == delta.X && d.Y == delta.Y { return i, nil } } return 0, errors.New("unable to determine direction") } // createBorder returns the slice of Points making up the border/contour // Also returns list of nbd's that are colliding with this. Can use to help create // tree with collision info later. func createBorder(img *common.SuzukiImage, p0 image.Point, p2 image.Point, nbd int, done []bool) ([]image.Point, map[int]bool, error) { // track which borders have conflicts collisionIndicies := make(map[int]bool) border := []image.Point{} dir, err := calcDir(p0, p2) if err != nil { log.Errorf("unable to determine direction: %s", err.Error()) return nil, nil, err } moved := clockwise(dir) p1 := image.Point{0, 0} for moved != dir { newP := move(p0, img, moved) if newP.Y != 0 { p1 = newP break } moved = clockwise(moved) } if p1.X == 0 && p1.Y == 0 { return []image.Point{}, collisionIndicies, nil } p2 = p1 p3 := p0 for { dir, err = calcDir(p3, p2) if err != nil { log.Errorf("unable to determine direction: %s", err.Error()) return nil, nil, err } moved = counterClockwise(dir) p4 := image.Point{0, 0} done = []bool{false, false, false, false, false, false, false, false} for { p4 = move(p3, img, moved) if p4.Y != 0 { break } done[moved] = true moved = counterClockwise(moved) } // detect if colliding with something else (ie not 0 nor 1) curP3 := img.Get(p3) if curP3 != 1 { if curP3 < 0 { curP3 *= -1 } absNbd := nbd if absNbd < 0 { absNbd *= -1 } collisionIndicies[curP3] = true collisionIndicies[absNbd] = true } border = append(border, p3) if p3.Y == img.Height-1 || done[2] { img.Set(p3, -1*nbd) } else if img.Get(p3) == 1 { img.Set(p3, nbd) } if p4.X == p0.X && p4.Y == p0.Y && p3.X == p1.X && p3.Y == p1.Y { break } p2 = p3 p3 = p4 } return border, collisionIndicies, nil } // addCollisionFlag mark contours with collisions with other contours. func addCollisionFlag(contour *Contour, parentId int, contours map[int]*Contour, collisionIndices map[int]bool) { for contour1 := range collisionIndices { // quick indicator to say colliding with parent. if contour1 == parentId { contour.ParentCollision = true } for contour2 := range collisionIndices { if contour1 != contour2 { c1 := contours[contour1] c1.ConflictingContours[contour2] = true } } } } // FindContours takes a SuzukiImage and determines the Contours that are present. // It returns the single parent contour which in turn has all other contours as children or further // generations. func FindContours(img *common.SuzukiImage) (*Contour, error) { nbd := 1 lnbd := 1 contours := make(map[int]*Contour) done := []bool{false, false, false, false, false, false, false, false} contour := NewContour(1) contours[lnbd] = contour height := img.Height width := img.Width for i := 0; i < height; i++ { lnbd = 1 for j := 0; j < width; j++ { fji := img.GetXY(j, i) isOuter := fji == 1 && (j == 0 || img.GetXY(j-1, i) == 0) isHole := fji >= 1 && (j == width-1 || img.GetXY(j+1, i) == 0) if isOuter || isHole { var contourPrime *Contour contour := NewContour(1) from := image.Point{j, i} parentId := 0 if isOuter { nbd += 1 from = from.Sub(image.Point{1, 0}) contour.BorderType = Outer contourPrime = contours[lnbd] if contourPrime.BorderType == Outer { parentId = contourPrime.ParentId } else { parentId = contourPrime.Id } } else { nbd += 1 if fji > 1 { lnbd = fji } contourPrime = contours[lnbd] from = from.Add(image.Point{1, 0}) contour.BorderType = Hole if contourPrime.BorderType == Outer { parentId = contourPrime.Id } else { parentId = contourPrime.ParentId } } p0 := image.Point{j, i} border, collectionIndices, err := createBorder(img, p0, from, nbd, done) if err != nil { log.Errorf("unable to create border: %s", err.Error()) return nil, err } if len(border) == 0 { border = append(border, p0) img.Set(p0, -1*nbd) } if parentId != 0 { parent := contours[parentId] parent.Children = append(parent.Children, contour) contour.Parent = contours[parentId] } contour.ParentId = parentId contour.Points = border contour.Id = nbd contours[nbd] = contour addCollisionFlag(contour, parentId, contours, collectionIndices) } if fji != 0 && fji != 1 { lnbd = fji if lnbd < 0 { lnbd *= -1 } } } } finalContour := contours[1] // image was padded... so now shift every co-ord by -1,-1 if img.HasPadding() { shiftContour(finalContour) } return finalContour, nil } func shiftContour(contour *Contour) { for i, _ := range contour.Points { contour.Points[i].X = contour.Points[i].X - 1 contour.Points[i].Y = contour.Points[i].Y - 1 } for _, child := range contour.Children { shiftContour(child) } }
package border import ( "fmt" "image" "image/color" "image/png" _ "image/png" "os" "github.com/kpfaulkner/borders/common" image2 "github.com/kpfaulkner/borders/image" ) // LoadImage loads a PNG and returns a SuzukiImage. Currently restricted to PNG but will eventually expand // to include other formats. // // Erode parameter forces the eroding of the image before converting to a SuzukiImage. // See https://en.wikipedia.org/wiki/Erosion_(morphology) for explanation // // Dilate parameter forces the dilating of the image before converting to a SuzukiImage. Likewise, see // https://en.wikipedia.org/wiki/Dilation_(morphology) for explanation. // // The combination of Erode and Dilate helps remove any "spikes" that may appear in the generated boundary. // erode and dilate will usually be 0 (none) or 1 (single pixel spikes) func LoadImage(filename string, erode int, dilate int) (*common.SuzukiImage, error) { f, err := os.Open(filename) if err != nil { return nil, err } defer f.Close() img, _, err := image.Decode(f) if err != nil { return nil, err } // If any pixels on the edges are populated, then we need to pad this out by 1 pixel on each side. // This will be reversed later. requirePadding := doesImageRequirePadding(img) // need border to be black. Pad edges with 1 black pixel si := common.NewSuzukiImage(img.Bounds().Dx(), img.Bounds().Dy(), requirePadding) paddingOffset := 0 if requirePadding { paddingOffset = 1 } // dumb... but convert to own image format for now. for y := 0; y < img.Bounds().Dy(); y++ { for x := 0; x < img.Bounds().Dx(); x++ { cc := 0 c := img.At(x, y) r, g, b, _ := c.RGBA() if !(r == 0 && g == 0 && b == 0) { cc = 1 } si.SetXY(x+paddingOffset, y+paddingOffset, cc) } } if erode != 0 { si, err = image2.Erode(si, erode) if err != nil { return nil, err } } if dilate != 0 { si, err = image2.Dilate(si, dilate) if err != nil { return nil, err } } return si, nil } // check down each edge to see if populated, if so, it will require padding func doesImageRequirePadding(img image.Image) bool { // down left/right edge for y := 0; y < img.Bounds().Dy(); y++ { leftEdgeX := 0 c := img.At(leftEdgeX, y) r, g, b, _ := c.RGBA() if !(r == 0 && g == 0 && b == 0) { return true } rightEdgeX := img.Bounds().Dx() - 1 c = img.At(rightEdgeX, y) r, g, b, _ = c.RGBA() if !(r == 0 && g == 0 && b == 0) { return true } } // across top and bottom for x := 0; x < img.Bounds().Dx(); x++ { topEdgeY := 0 c := img.At(x, topEdgeY) r, g, b, _ := c.RGBA() if !(r == 0 && g == 0 && b == 0) { return true } bottomEdgeY := img.Bounds().Dy() - 1 c = img.At(x, bottomEdgeY) r, g, b, _ = c.RGBA() if !(r == 0 && g == 0 && b == 0) { return true } } return false } // SaveImage saves a SuzukiImage as a PNG to given filename. // Although currently only PNG, will make this more generic in the future. func SaveImage(filename string, si *common.SuzukiImage) error { upLeft := image.Point{0, 0} lowRight := image.Point{si.Width, si.Height} img := image.NewRGBA(image.Rectangle{upLeft, lowRight}) for x := 0; x < si.Width; x++ { for y := 0; y < si.Height; y++ { p := si.GetXY(x, y) if p == 1 { img.Set(x, y, color.White) } else { img.Set(x, y, color.Black) } } } f, _ := os.Create(filename) png.Encode(f, img) return nil } // SaveContourSliceImage saves a contour (and all child contours) as a PNG. // Width and height are the dimensions of the image to save. // // flipBook is a bool to indicate that when each child contour is added to the image, the image should be // saved to a new file with the filename suffixed with the count of contours added so far. This is useful // for debugging and visualising the contours as they are added. // // minContourSize indicates if minimum number of points that make up a contour. If contour contains fewer, then // do NOT save. func SaveContourSliceImage(filename string, c *Contour, width int, height int, flipBook bool, minContourSize int) error { upLeft := image.Point{0, 0} lowRight := image.Point{width, height} img := image.NewRGBA(image.Rectangle{upLeft, lowRight}) // naive fill for x := 0; x < width; x++ { for y := 0; y < height; y++ { img.Set(x, y, color.Black) } } colour := 0 count := 0 drawContour(img, c, flipBook, minContourSize, colour, &count, filename) f, _ := os.Create(filename) png.Encode(f, img) return nil } // drawContour saves a contour to the provided image and then recursively calls to save children to same image. func drawContour(img *image.RGBA, c *Contour, flipBook bool, minContourSize int, colour int, count *int, filename string) error { colours := []color.RGBA{ {255, 0, 0, 255}, {255, 106, 0, 255}, {255, 216, 0, 255}, {0, 255, 0, 255}, {127, 255, 197, 255}, {72, 0, 255, 255}, {255, 127, 182, 255}, } max := len(colours) if c.BorderType == Outer { colour = 0 } // draw contour itself. if len(c.Points) > 0 && len(c.Points) > minContourSize { colourToUse := colours[colour] for _, p := range c.Points { img.Set(p.X, p.Y, colourToUse) } colour++ if colour >= max { colour = 0 } // save new image per contour added... crazy if flipBook { fn := fmt.Sprintf("%s-%d.png", filename, *count) f, _ := os.Create(fn) png.Encode(f, img) f.Close() } *count = *count + 1 } for _, child := range c.Children { colour++ if colour >= max { colour = 0 } *count = *count + 1 drawContour(img, child, flipBook, minContourSize, colour, count, filename) } return nil }
package common import ( "fmt" "image" "strings" ) // SuzukiImage is the basic structure we use to define an image when trying to find contours. type SuzukiImage struct { Width int Height int data []int dataLen int // Indicates if a 1 pixel padding has been applied to around the image. // This helps with imagery where it goes RIGHT up to the edge. hasPadding bool } // NewSuzukiImage creates a new SuzukiImage of specific dimensions. func NewSuzukiImage(width int, height int, hasPadding bool) *SuzukiImage { si := SuzukiImage{} padding := 0 if hasPadding { padding = 2 } si.Width = width + padding si.Height = height + padding si.data = make([]int, si.Width*si.Height) si.dataLen = si.Width * si.Height // just saves us calculating a lot si.hasPadding = hasPadding return &si } func NewSuzukiImageFromData(width int, height int, hasPadding bool, data []int) *SuzukiImage { si := NewSuzukiImage(width, height, hasPadding) si.data = data[:] si.dataLen = len(data) return si } // Get returns the value of a given point func (si *SuzukiImage) GetAllData() []int { return si.data } // Get returns the value of a given point func (si *SuzukiImage) Get(p image.Point) int { idx := p.Y*si.Width + p.X return si.data[idx] } // GetXY returns the value of a given x/y func (si *SuzukiImage) GetXY(x int, y int) int { idx := y*si.Width + x return si.data[idx] } // Set sets the value at a given point func (si *SuzukiImage) Set(p image.Point, val int) { idx := p.Y*si.Width + p.X si.data[idx] = val } // SetXY sets the value at a given x/y func (si *SuzukiImage) SetXY(x int, y int, val int) { idx := y*si.Width + x si.data[idx] = val } func (si *SuzukiImage) HasPadding() bool { return si.hasPadding } // DisplayAsText generates a string of a given image. This is purely used for debugging SMALL images func (si *SuzukiImage) DisplayAsText() []string { s := []string{} for y := 0; y < si.Height; y++ { ss := si.data[y*si.Width : (y*si.Width + si.Width)] t := []string{} for _, i := range ss { t = append(t, fmt.Sprintf("%d", i)) } s = append(s, strings.Join(t, " ")+"\n") } return s } // Equals checks if two SuzukiImages are equal. func (si *SuzukiImage) Equals(other *SuzukiImage) bool { if si.Width != other.Width || si.Height != other.Height { return false } for i := 0; i < si.dataLen; i++ { if si.data[i] != other.data[i] { return false } } return true }
package converters import ( "errors" "image" "math" "github.com/kpfaulkner/borders/border" "github.com/peterstace/simplefeatures/geom" ) const ( EarthRadius = 6378137.0 toleranceInMetres = 2 ) type PointConverter func(x float64, y float64) (float64, float64) // NewSlippyToLatLongConverter returns a function that converts slippy tile coordinates to lat/long. func NewSlippyToLatLongConverter(slippyXOffset float64, slippyYOffset float64, scale int) func(X float64, Y float64) (float64, float64) { latLongN := math.Pow(2, float64(scale)) f := func(x float64, y float64) (float64, float64) { long, lat := slippyCoordsToLongLat(slippyXOffset, slippyYOffset, x, y, latLongN) return long, lat } return f } // LatLongToSlippy converts lat/long to slippy tile coordinates. func LatLongToSlippy(latDegrees float64, longDegrees float64, scale int) (float64, float64) { n := math.Exp2(float64(scale)) x := int(math.Floor((longDegrees + 180.0) / 360.0 * n)) if float64(x) >= n { x = int(n - 1) } y := int(math.Floor((1.0 - math.Log(math.Tan(latDegrees*math.Pi/180.0)+1.0/math.Cos(latDegrees*math.Pi/180.0))/math.Pi) / 2.0 * n)) return float64(x), float64(y) } // ConvertContourToPolygon converts the contours (set of x/y coords) to geometries commonly used in the GIS space // Convert to polygons, then simplify (if required) while still in "pixel space" // Only then apply conversions which may be to lat/long (or any other conversions). // Simplifying while in "pixel space" simplifies the simplification degTolerance calculation. // params: // // simplify: Simplify the resulting polygons // minPoints: Minimum number of vertices for a polygon to be considered valid. If less than this, then will be discarded. 0 means no minimum // degTolerance: Tolerance in pixels when simplifying. If set to 0, then will use defaults. // multiPolygonOnly: If the geometry is results in a GeometryCollection, then extract out the multipolygon part and return that. // pointConverters: Used to convert point co-ord systems. eg. slippy to lat/long. func ConvertContourToPolygon(c *border.Contour, scale int, simplify bool, minPoints int, tolerance float64, multiPolygonOnly bool, pointConverters ...PointConverter) (*geom.Geometry, error) { polygons := []geom.Polygon{} err := convertContourToPolygons(c, minPoints, &polygons) if err != nil { return nil, err } mp := geom.NewMultiPolygon(polygons) if simplify { if tolerance == 0 { tolerance = generateSimplifyTolerance(scale) } gg := mp.AsGeometry() simplifiedGeom, err := gg.Simplify(tolerance, geom.NoValidate{}) if err != nil { return nil, err } if multiPolygonOnly { if simplifiedGeom.Type() == geom.TypeMultiPolygon { mp, _ = simplifiedGeom.AsMultiPolygon() return returnConvertedGeometry(&mp, pointConverters...) } //////////////////////// // Need to check if this is still possible. // need to check when we get geometrycollection vs multipolygon //if simplifiedGeom.Type() == geom.TypeGeometryCollection { // gc, ok := simplifiedGeom.AsGeometryCollection() // if ok { // mp, err := filterMultiPolygonFromGeometryCollection(&gc) // if err == nil { // return returnConvertedGeometry(mp, pointConverters...) // } // } //} //////////////////////// return nil, errors.New("unable to filter multipolygon from geometry collection") } mp, ok := simplifiedGeom.AsMultiPolygon() if ok { return returnConvertedGeometry(&mp, pointConverters...) } else { return nil, errors.New("unable to convert simplified geom to multipolygon") } } return returnConvertedGeometry(&mp, pointConverters...) } // returnConvertedGeometry converts the multipolygon with PointConverters (if supplied) // Can be used to help convert to lat/long or any other co-ordinate system. func returnConvertedGeometry(mp *geom.MultiPolygon, pointConverters ...PointConverter) (*geom.Geometry, error) { finalMultiPoly, err := convertCoords(mp, pointConverters...) if err != nil { return nil, err } g := finalMultiPoly.AsGeometry() return &g, nil } // convertCoords converts the coordinates of a multipolygon using the supplied PointConverters. func convertCoords(mp *geom.MultiPolygon, converters ...PointConverter) (*geom.MultiPolygon, error) { mp2 := mp.TransformXY(func(xy geom.XY) geom.XY { x := xy.X y := xy.Y // run through converters. for _, converter := range converters { newX, newY := converter(x, y) x = newX y = newY } return geom.XY{X: x, Y: y} }) return &mp2, nil } // generateLineString generates a LineString from a slice of image.Points. func generateLineString(points []image.Point) (*geom.LineString, error) { seq := pointsToSequence(points) if seq.Length() > 2 { ls := geom.NewLineString(seq) // if linestring only has 1 value, then ditch. if seq.Length() >= 1 { return &ls, nil } } return &geom.LineString{}, nil } // convertContourToPolygons converts the contour to a set of polygons but does NOT convert to different co-ord systems. // If a polygon has fewer than minPoints then it will be discarded. 0 means no min points. func convertContourToPolygons(c *border.Contour, minPoints int, polygons *[]geom.Polygon) error { // outer... so make a poly // will also cover hole if there. if c.BorderType == border.Outer { lineStrings := []geom.LineString{} outerLS, err := generateLineString(c.Points) if err != nil { return err } lineStrings = append(lineStrings, *outerLS) // now get children... (holes). for _, child := range c.Children { if !child.ParentCollision && child.Usable { ls, err := generateLineString(child.Points) if err != nil { return err } lineStrings = append(lineStrings, *ls) } } var poly geom.Polygon if minPoints == 0 || len(lineStrings) > minPoints { poly = geom.NewPolygon(lineStrings) *polygons = append(*polygons, poly) } } for _, child := range c.Children { // only process child if no conflict with parent. if !child.ParentCollision && child.Usable { err := convertContourToPolygons(child, minPoints, polygons) if err != nil { return err } } } return nil } // pointsToSequence converts a slice of image.Points to a geom.Sequence. func pointsToSequence(points []image.Point) geom.Sequence { s := len(points)*2 + 2 seq := make([]float64, s, s) index := 0 for _, origP := range points { x, y := float64(origP.X), float64(origP.Y) seq[index] = x seq[index+1] = y index += 2 } seq[index] = seq[0] seq[index+1] = seq[1] return geom.NewSequence(seq, geom.DimXY) } // slippyCoordsToLongLat converts to lat/long... and requires the slippy offset of top left corner of area. func slippyCoordsToLongLat(slippyXOffset float64, slippyYOffset float64, xTile float64, yTile float64, latLongN float64) (float64, float64) { x := xTile + slippyXOffset y := yTile + slippyYOffset longDeg := (x/latLongN)*360.0 - 180.0 latRad := math.Atan(math.Sinh(math.Pi - (y/latLongN)*2*math.Pi)) latDeg := latRad * (180.0 / math.Pi) return longDeg, latDeg } // generateSimplifyTolerance will mainly be used when we want to convert to geographical co-ordinates // By default we will determine how many metres per pixel (for input scale/zoom) and double it. func generateSimplifyTolerance(scale int) float64 { mtrPerPixel := metresPerPixel(scale) tolerance := mtrPerPixel * toleranceInMetres return tolerance } // tileSizeInMetres is the size of a tile in metres. func tileSizeInMetres(scale int) float64 { return 2 * math.Pi * EarthRadius / float64(uint64(1)<<uint64(scale)) } // metresPerPixel is number of metres for a given input pixel. This is based on the scale/zoom. func metresPerPixel(scale int) float64 { return tileSizeInMetres(scale) / 256.0 } // filterMultiPolygonFromGeometryCollection currently unused. Will be used in upcoming version. func filterMultiPolygonFromGeometryCollection(col *geom.GeometryCollection) (*geom.MultiPolygon, error) { var mp geom.MultiPolygon var ok bool for i := 0; i < col.NumGeometries(); i++ { g := col.GeometryN(i) mp, ok = g.AsMultiPolygon() if ok { return &mp, nil } } return nil, errors.New("no multipolygon found in geometry collection") }
package image import ( "github.com/kpfaulkner/borders/common" ) // Erode the suzuki image, based on Morphological Erosion // https://en.wikipedia.org/wiki/Erosion_(morphology) // Although based on the above, we always need to make sure the border of the image is all 0. func Erode(img *common.SuzukiImage, radius int) (*common.SuzukiImage, error) { img2 := common.NewSuzukiImage(img.Width, img.Height, img.HasPadding()) for y := 0; y < img.Height; y++ { for x := 0; x < img.Width; x++ { // if x == 0 or y == 0 or x == img.Width-1 or y == img.Height-1 then its an edge, and set it to 0. if x == 0 || y == 0 || x == img.Width-1 || y == img.Height-1 { img.SetXY(x, y, 0) continue } // for each pixel, check if all pixels within radius are 1 // if not, set to 0 if img.GetXY(x, y) == 1 { // check if all pixels within radius are 1 // if not, set to 0 if !checkErodeRadius(img, x, y, img.Width, img.Height, radius) { img2.SetXY(x, y, 0) } else { img2.SetXY(x, y, 1) } } } } return img2, nil } func checkErodeRadius(img *common.SuzukiImage, x int, y int, width int, height int, radius int) bool { for i := -radius; i <= radius; i++ { for j := -radius; j <= radius; j++ { if x+i < 0 || y+j < 0 || x+i >= width || y+j >= height { continue // out of bounds. } if img.GetXY(x+i, y+j) != 1 { return false } } } return true } // Dilate the suzuki image, based on Morphological Dilation // https://en.wikipedia.org/wiki/Dilation_(morphology) func Dilate(img *common.SuzukiImage, radius int) (*common.SuzukiImage, error) { img2 := common.NewSuzukiImage(img.Width, img.Height, img.HasPadding()) for y := 0; y < img.Height; y++ { for x := 0; x < img.Width; x++ { // for each pixel, check if any pixels within radius are 1 // if not, set to 0 if img.GetXY(x, y) == 1 { dilateRadiusAroundPoint(img2, x, y, img.Width, img.Height, radius) } } } return img2, nil } func dilateRadiusAroundPoint(img2 *common.SuzukiImage, x int, y int, width int, height int, radius int) { for i := -radius; i <= radius; i++ { for j := -radius; j <= radius; j++ { if x+i < 0 || y+j < 0 || x+i >= width || y+j >= height { continue // out of bounds. } img2.SetXY(x+i, y+j, 1) } } }