331 lines
9.4 KiB
Go
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
|
|
}
|