Tooling
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user