Files
2026-09-25 17:02:24 +03:00

89 lines
3.9 KiB
Go

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
)