package template import "salty/terrain/internal/field" // Despeckle removes the hairline of a class nobody painted that appears along a boundary between two others. // // It exists because of a measured failure, and the arithmetic is worth keeping because it will happen to // anybody who exports a lossy image. Classification gives every pixel its nearest class in RGB, and a codec // blends across every boundary it finds. On the first template the antialiased edge between `surf` // (221,238,238) and `lowland` (153,204,102) comes out at about (186,219,174), whose distance to `desert` // (238,221,153) is **53.8** against **77.9** to either of the colours it was actually mixed from. So every // temperate coast on the map gained a one-pixel ribbon of desert - 1607 pixels of it nowhere near the real // desert - and it was invisible until the coast mask made those strays the nearest *land* to a stretch of // open water and handed their class to every cell it turned into shore. An eleven-pixel band of desert // appeared along a green continent, and the mask was blamed for it first. // // **The test is spatial, and it has to be.** The obvious fix is colorimetric - notice that the pixel lies on // the line between two class colours and give it to the nearer one - and it was built, measured and thrown // away, because it cannot work in general and this legend is the proof: `shelf` (153,204,221) sits 10 units // from the line between `ocean` and `surf`, so a real shelf pixel with a little codec noise on it and a // genuine ocean/surf blend are the same point in colour space. That rule reclassified 943 000 painted shelf // pixels. What actually distinguishes a stray is *where* it is: a class nobody painted here occupies a line // one pixel wide with two other classes on either side of it, and no painted feature at 12.9 m a pixel is // one pixel wide - the one thing that was, the decorative stroke, has a pass of its own. // // Hence a five by five window rather than three by three. A one-pixel ribbon running through the middle of a // 3x3 holds three of its nine cells and the two classes it divides hold three each, so nothing has a // majority and the rule cannot fire; over 5x5 the ribbon holds five of twenty-five against ten and ten, which // is the signature being looked for. A feature two pixels wide already holds ten and is left alone. func (r *Raster) Despeckle() int { if r.W < despeckleWindow || r.H < despeckleWindow { return 0 } out := make([]uint8, len(r.Class)) copy(out, r.Class) const rad = despeckleWindow / 2 counts := make([]int32, field.BandCount(r.H)*256) changed := make([]int, field.BandCount(r.H)) field.RowsIndexed(r.H, func(band, y0, y1 int) { c := counts[band*256 : band*256+256] for y := y0; y < y1; y++ { for x := 0; x < r.W; x++ { self := r.Class[y*r.W+x] for dy := -rad; dy <= rad; dy++ { for dx := -rad; dx <= rad; dx++ { c[r.At(x+dx, y+dy)]++ } } best, bestN := self, int32(0) for i := range c { if c[i] > bestN || (c[i] == bestN && uint8(i) < best) { best, bestN = uint8(i), c[i] } } selfN := c[self] for i := range c { c[i] = 0 } if selfN <= despeckleThin && bestN >= despeckleMajority && best != self { out[y*r.W+x] = best changed[band]++ } } } }) total := 0 for _, n := range changed { total += n } r.Class = out return total } const ( // despeckleWindow is five: see the note above on why three is too small to see a ribbon at all. despeckleWindow = 5 // despeckleThin is how little of its own window a class may hold and still be called a stray. Five of // twenty-five is a line one pixel wide straight through the middle; a feature two pixels wide holds ten. despeckleThin = 5 // despeckleMajority is how much of the window the replacement has to hold. Eight of twenty-five means // there is something clearly there to join, so a pixel in genuinely mixed country is left as it is. despeckleMajority = 8 )