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

331 lines
9.4 KiB
Go

package template
import (
"fmt"
"math"
"salty/terrain/internal/field"
)
// Raster is a class index per pixel, row-major. X wraps; Y does not.
type Raster struct {
W, H int
Class []uint8
}
// At reads a pixel, wrapping X and clamping Y, which is the convention every cylindrical map in this
// tree follows: the left and right edges are the same meridian, the top and bottom are the poles.
func (r *Raster) At(x, y int) uint8 {
x = ((x % r.W) + r.W) % r.W
if y < 0 {
y = 0
} else if y >= r.H {
y = r.H - 1
}
return r.Class[y*r.W+x]
}
// Match is what the classifier saw, and it is the first thing to read when a template comes out wrong.
//
// Every pixel is assigned to its nearest class, so a colour the legend has never heard of does not fail
// the run - it quietly becomes whatever it happens to be closest to. Far and MaxDist are what make that
// visible.
type Match struct {
Total int
Counts []int // per class
Far int // pixels further than the legend's WarnDistance from every class
MaxDist float64
MaxAt [2]int // where the worst one was
// The wrap: how well the painting's left and right edges agree. They are the same meridian, so a
// template that does not wrap produces a real discontinuity down one line of the world and there is no
// way to see it by looking at the picture - the two edges are as far apart on screen as they can be.
WrapRows int // rows compared
WrapDiffer int // rows where the two edges classify differently
WrapLandSea int // rows where one edge is land and the other water: the visible kind
WrapFarEdge int // pixels in the first or last two columns that no class is near
}
func (m Match) String() string {
return fmt.Sprintf("%d px, %d further than the warn distance from any class (worst %.0f at %d,%d)",
m.Total, m.Far, m.MaxDist, m.MaxAt[0], m.MaxAt[1])
}
// WrapReport is the one-line verdict on whether the painting is a cylinder.
func (m Match) WrapReport() string {
if m.WrapRows == 0 {
return "wrap not measured"
}
return fmt.Sprintf("the edges disagree on %d of %d rows (%.1f%%), %d of them land against water; "+
"%d px in the outermost columns match no class",
m.WrapDiffer, m.WrapRows, 100*float64(m.WrapDiffer)/float64(m.WrapRows), m.WrapLandSea, m.WrapFarEdge)
}
// measureWrap compares the first and last columns, which are the same meridian.
func (l *Legend) measureWrap(px []uint8, w, h int, r *Raster, m *Match) {
if w < 2 {
return
}
m.WrapRows = h
warn2 := l.WarnDistance * l.WarnDistance
for y := 0; y < h; y++ {
a := r.Class[y*w]
b := r.Class[y*w+w-1]
if a != b {
m.WrapDiffer++
if l.Classes[a].Sea != l.Classes[b].Sea {
m.WrapLandSea++
}
}
// The outermost columns are where a lossy encoder leaves its halo, and a halo on the seam is a
// stripe of the wrong class down the one line of the world where it cannot be hidden.
for _, x := range [4]int{0, 1, w - 2, w - 1} {
o := (y*w + x) * 3
if float64(l.nearestDist2(int(px[o]), int(px[o+1]), int(px[o+2]))) > warn2 {
m.WrapFarEdge++
}
}
}
}
// nearestDist2 is the squared RGB distance to the closest painted class.
func (l *Legend) nearestDist2(r, g, b int) int {
best := 1 << 30
for ci := range l.Classes {
c := &l.Classes[ci]
if c.Derived {
continue
}
dr, dg, db := r-c.RGB[0], g-c.RGB[1], b-c.RGB[2]
if d := dr*dr + dg*dg + db*db; d < best {
best = d
}
}
return best
}
// Classify assigns every pixel to the nearest class in RGB.
//
// Nearest rather than within-a-tolerance, so the result is total: there is no unclassified pixel to
// decide what to do with later, and a stray artefact - a JPEG ringing overshoot, the one black pixel in
// the left column of the template this was written for - lands on something sensible instead of
// punching a hole in the world. The Match report is what says it happened.
func (l *Legend) Classify(px []uint8, w, h int) (*Raster, Match) {
r := &Raster{W: w, H: h, Class: make([]uint8, w*h)}
// Reduction into pre-allocated indexed slots, never a channel drain: the result must not depend on
// which goroutine finished first (cross-cutting rule 12).
partial := make([]Match, field.BandCount(h))
for i := range partial {
partial[i].Counts = make([]int, len(l.Classes))
}
warn2 := l.WarnDistance * l.WarnDistance
field.RowsIndexed(h, func(band, y0, y1 int) {
p := &partial[band]
for y := y0; y < y1; y++ {
for x := 0; x < w; x++ {
o := (y*w + x) * 3
cr, cg, cb := int(px[o]), int(px[o+1]), int(px[o+2])
best, bestD := -1, 1<<30
for ci := range l.Classes {
c := &l.Classes[ci]
if c.Derived {
continue // never painted, so never matched
}
dr := cr - c.RGB[0]
dg := cg - c.RGB[1]
db := cb - c.RGB[2]
d := dr*dr + dg*dg + db*db
if d < bestD {
bestD, best = d, ci
}
}
r.Class[y*w+x] = uint8(best)
p.Total++
p.Counts[best]++
if float64(bestD) > warn2 {
p.Far++
}
if float64(bestD) > p.MaxDist {
p.MaxDist = float64(bestD)
p.MaxAt = [2]int{x, y}
}
}
}
})
out := Match{Counts: make([]int, len(l.Classes))}
out.MaxAt = [2]int{-1, -1}
for i := range partial {
p := &partial[i]
out.Total += p.Total
out.Far += p.Far
for c, n := range p.Counts {
out.Counts[c] += n
}
// The tie-break keeps the report itself independent of GOMAXPROCS, which changes how many bands
// there are: without it two pixels at the same distance could be reported in either order.
if p.Total > 0 && (p.MaxDist > out.MaxDist ||
(p.MaxDist == out.MaxDist && earlier(p.MaxAt, out.MaxAt))) {
out.MaxDist = p.MaxDist
out.MaxAt = p.MaxAt
}
}
out.MaxDist = math.Sqrt(out.MaxDist) // kept squared through the loops; reported as a distance
l.measureWrap(px, w, h, r, &out)
return r, out
}
func earlier(a, b [2]int) bool {
if b[1] < 0 {
return true
}
if a[1] != b[1] {
return a[1] < b[1]
}
return a[0] < b[0]
}
// DissolveStrokes removes the decoration an artist drew and leaves only classes that mean something.
//
// Two rules, in this order:
//
// 1. A stroke region that touches the top or bottom row of the map is not a stroke. It becomes the
// class its edge_class names. This is what tells a polar ice cap from the white outline drawn
// around every island when both are painted the same white, and it is the whole reason the rule
// exists.
// 2. Every remaining stroke pixel takes the class of the nearest pixel that is not a stroke, measured
// outwards from all of them at once. A ring sitting between land and water is therefore split down
// its middle rather than given wholly to one side, which is the only answer that does not move the
// coastline by the width of the artist's brush.
//
// Returns how many pixels each rule rewrote.
func (r *Raster) DissolveStrokes(l *Legend) (edge, dissolved int) {
stroke := make([]bool, len(l.Classes))
any := false
for i := range l.Classes {
stroke[i] = l.Classes[i].Stroke
any = any || stroke[i]
}
if !any {
return 0, 0
}
edge = r.rewriteEdgeStrokes(l, stroke)
// Rule 2. Seed from every non-stroke pixel that touches a stroke pixel, then walk outwards through
// stroke pixels only. Seeds are pushed in raster order and the queue is FIFO, so the result does not
// depend on anything but the image.
queue := make([]int32, 0, 1<<16)
filled := make([]bool, len(r.Class))
for y := 0; y < r.H; y++ {
for x := 0; x < r.W; x++ {
i := y*r.W + x
if stroke[r.Class[i]] {
continue
}
if r.hasStrokeNeighbour(x, y, stroke) {
queue = append(queue, int32(i))
filled[i] = true
}
}
}
for head := 0; head < len(queue); head++ {
i := int(queue[head])
c := r.Class[i]
x, y := i%r.W, i/r.W
for _, n := range r.neighbours(x, y) {
if n < 0 || filled[n] || !stroke[r.Class[n]] {
continue
}
r.Class[n] = c
filled[n] = true
dissolved++
queue = append(queue, int32(n))
}
}
return edge, dissolved
}
// rewriteEdgeStrokes applies rule 1: flood each stroke class inwards from the poles.
func (r *Raster) rewriteEdgeStrokes(l *Legend, stroke []bool) int {
n := 0
stack := make([]int32, 0, 1<<16)
for ci := range l.Classes {
if !stroke[ci] {
continue
}
to := l.EdgeIndex(ci)
if to < 0 {
continue
}
want := uint8(ci)
become := uint8(to)
stack = stack[:0]
push := func(x, y int) {
i := y*r.W + x
if r.Class[i] == want {
r.Class[i] = become
n++
stack = append(stack, int32(i))
}
}
for x := 0; x < r.W; x++ {
push(x, 0)
push(x, r.H-1)
}
for len(stack) > 0 {
i := int(stack[len(stack)-1])
stack = stack[:len(stack)-1]
x, y := i%r.W, i/r.W
for _, m := range r.neighbours(x, y) {
if m >= 0 && r.Class[m] == want {
r.Class[m] = become
n++
stack = append(stack, int32(m))
}
}
}
}
return n
}
// neighbours is the eight-connected neighbourhood with X wrapped and Y bounded. -1 means off the map,
// which only ever happens past a pole.
func (r *Raster) neighbours(x, y int) [8]int {
var out [8]int
k := 0
for dy := -1; dy <= 1; dy++ {
ny := y + dy
for dx := -1; dx <= 1; dx++ {
if dx == 0 && dy == 0 {
continue
}
if ny < 0 || ny >= r.H {
out[k] = -1
k++
continue
}
nx := x + dx
if nx < 0 {
nx += r.W
} else if nx >= r.W {
nx -= r.W
}
out[k] = ny*r.W + nx
k++
}
}
return out
}
func (r *Raster) hasStrokeNeighbour(x, y int, stroke []bool) bool {
for _, n := range r.neighbours(x, y) {
if n >= 0 && stroke[r.Class[n]] {
return true
}
}
return false
}