Tooling
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"image"
|
||||
"image/png"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
@@ -45,6 +46,48 @@ func WriteGray8(path string, w, h int, values []uint8, level png.CompressionLeve
|
||||
return encode(path, img, level)
|
||||
}
|
||||
|
||||
// WriteRGB writes tightly packed 8-bit RGB, three bytes a pixel. It is what a categorical map wants: a class
|
||||
// raster has no ramp to run through a palette, only a colour per class.
|
||||
// WriteRGBA writes colour with a separate alpha plane, which is what an overlay sheet is: strokes on a
|
||||
// transparent background, where blank is decided by alpha rather than by a reserved colour.
|
||||
//
|
||||
// Non-premultiplied (NRGBA), deliberately. A mark's colour has to come back out of the file exactly as it
|
||||
// went in, because the classifier reads exact colours against a tolerance; premultiplying would scale every
|
||||
// channel by the alpha and round on the way, and a mark would classify as something else or as nothing.
|
||||
func WriteRGBA(path string, w, h int, px []uint8, alpha []uint8, level png.CompressionLevel) error {
|
||||
if len(px) != w*h*3 {
|
||||
return fmt.Errorf("%s: %d bytes for a %dx%d RGB image", path, len(px), w, h)
|
||||
}
|
||||
if len(alpha) != w*h {
|
||||
return fmt.Errorf("%s: %d alpha bytes for a %dx%d image", path, len(alpha), w, h)
|
||||
}
|
||||
img := image.NewNRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
row := img.Pix[y*img.Stride:]
|
||||
src := px[y*w*3:]
|
||||
a := alpha[y*w:]
|
||||
for x := 0; x < w; x++ {
|
||||
row[x*4], row[x*4+1], row[x*4+2], row[x*4+3] = src[x*3], src[x*3+1], src[x*3+2], a[x]
|
||||
}
|
||||
}
|
||||
return encode(path, img, level)
|
||||
}
|
||||
|
||||
func WriteRGB(path string, w, h int, px []uint8, level png.CompressionLevel) error {
|
||||
if len(px) != w*h*3 {
|
||||
return fmt.Errorf("%s: %d bytes for a %dx%d RGB image", path, len(px), w, h)
|
||||
}
|
||||
img := image.NewNRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
row := img.Pix[y*img.Stride:]
|
||||
src := px[y*w*3:]
|
||||
for x := 0; x < w; x++ {
|
||||
row[x*4], row[x*4+1], row[x*4+2], row[x*4+3] = src[x*3], src[x*3+1], src[x*3+2], 255
|
||||
}
|
||||
}
|
||||
return encode(path, img, level)
|
||||
}
|
||||
|
||||
func encode(path string, img image.Image, level png.CompressionLevel) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
@@ -163,18 +206,60 @@ func isqrt(n int) int {
|
||||
return r
|
||||
}
|
||||
|
||||
// WriteHillshade writes an 8-bit relief shade of a height field, at any scale.
|
||||
//
|
||||
// It is not WriteThumbnail with a bigger number, and the difference is the whole reason it exists.
|
||||
// WriteThumbnail's shading term is a raw gradient over the cell size, which is fine on a 512-pixel picture of
|
||||
// a whole map where the gradients are small, and saturates to pure black and white the moment it is used on
|
||||
// real ground at two metres a cell. The result reads as flat-topped terraces with hard edges - a mountainside
|
||||
// rendered as a staircase - and it is convincing enough to be mistaken for a defect in the terrain. It was.
|
||||
//
|
||||
// This is the standard DEM hillshade instead: the surface normal against a light from the north-west at 45
|
||||
// degrees, which is bounded by construction and says the same thing at any cell size.
|
||||
func WriteHillshade(path string, h *Field, size int, exaggeration float64) error {
|
||||
sizeH := aspectH(h, size)
|
||||
small := h
|
||||
if size != h.W || sizeH != h.H {
|
||||
small = h.Resample(size, sizeH)
|
||||
}
|
||||
exag := exaggeration
|
||||
if exag <= 0 {
|
||||
exag = 1
|
||||
}
|
||||
px := make([]uint8, size*sizeH)
|
||||
Rows(sizeH, func(y0, y1 int) {
|
||||
for y := y0; y < y1; y++ {
|
||||
for x := 0; x < size; x++ {
|
||||
gx := float64(small.AtClamped(x+1, y)-small.AtClamped(x-1, y)) * exag
|
||||
gy := float64(small.AtClamped(x, y+1)-small.AtClamped(x, y-1)) * exag
|
||||
slope := math.Atan(math.Hypot(gx, gy) / (2 * small.CellM))
|
||||
aspect := math.Atan2(gy, -gx)
|
||||
lum := math.Cos(slope)*math.Cos(math.Pi/4) +
|
||||
math.Sin(slope)*math.Sin(math.Pi/4)*math.Cos(3*math.Pi/4-aspect)
|
||||
v := 0.25 + 0.75*math.Max(0, lum)
|
||||
if v > 1 {
|
||||
v = 1
|
||||
}
|
||||
px[y*size+x] = uint8(v * 255)
|
||||
}
|
||||
}
|
||||
})
|
||||
return WriteGray8(path, size, sizeH, px, png.BestSpeed)
|
||||
}
|
||||
|
||||
// WriteThumbnail writes a small 8-bit preview of a height field, hillshaded so the drainage is actually
|
||||
// visible: a flat grey ramp hides exactly the thing this generator exists to produce.
|
||||
func WriteThumbnail(path string, h *Field, size int) error {
|
||||
small := h.Resample(size, size)
|
||||
sizeH := aspectH(h, size)
|
||||
small := h.Resample(size, sizeH)
|
||||
lo, hi := small.MinMax()
|
||||
span := float64(hi - lo)
|
||||
if span < 1e-6 {
|
||||
span = 1
|
||||
}
|
||||
// Light from the north-west at 45 degrees, the convention every DEM hillshade uses.
|
||||
px := make([]uint8, size*size)
|
||||
for y := 0; y < size; y++ {
|
||||
px := make([]uint8, size*sizeH)
|
||||
for y := 0; y < sizeH; y++ {
|
||||
for x := 0; x < size; x++ {
|
||||
gx := float64(small.AtClamped(x+1, y) - small.AtClamped(x-1, y))
|
||||
gy := float64(small.AtClamped(x, y+1) - small.AtClamped(x, y-1))
|
||||
@@ -189,7 +274,7 @@ func WriteThumbnail(path string, h *Field, size int) error {
|
||||
px[y*size+x] = uint8(lum * 255)
|
||||
}
|
||||
}
|
||||
return WriteGray8(path, size, size, px, png.BestSpeed)
|
||||
return WriteGray8(path, size, sizeH, px, png.BestSpeed)
|
||||
}
|
||||
|
||||
var _ io.Writer = (*bufio.Writer)(nil)
|
||||
|
||||
Reference in New Issue
Block a user