File: fh/config.go 1 package main 2 3 import ( 4 "errors" 5 "fmt" 6 "image/color" 7 "math" 8 "os" 9 "strconv" 10 "strings" 11 ) 12 13 const ( 14 // all output formats as constants, to prevent typos 15 pngOutput = `png` 16 pngFastOutput = `fast-png` 17 pngSmallestOutput = `smallest-png` 18 pngUncompressedOutput = `uncompressed-png` 19 bmpOutput = `bmp` 20 jpegOutput = `jpeg` 21 22 // all colorscales as constants, to prevent typos 23 magmaScale = `magma` 24 parulaScale = `parula` 25 viridisScale = `viridis` 26 grayScale = `gray` 27 binaryScale = `binary` 28 signScale = `sign` 29 ) 30 31 // fmtAliases normalizes values for the output-format option 32 var fmtAliases = map[string]string{ 33 `b`: bmpOutput, 34 `bitmap`: bmpOutput, 35 `bmp`: bmpOutput, 36 `j`: jpegOutput, 37 `jpeg`: jpegOutput, 38 `jpg`: jpegOutput, 39 `p`: pngOutput, 40 `ping`: pngOutput, 41 `png`: pngOutput, 42 43 `f`: pngFastOutput, 44 `fast`: pngFastOutput, 45 `fast-png`: pngFastOutput, 46 `fp`: pngFastOutput, 47 `fpng`: pngFastOutput, 48 `s`: pngSmallestOutput, 49 `small`: pngSmallestOutput, 50 `smallest-png`: pngSmallestOutput, 51 `small-png`: pngSmallestOutput, 52 `sp`: pngSmallestOutput, 53 `spng`: pngSmallestOutput, 54 `u`: pngUncompressedOutput, 55 `unc`: pngUncompressedOutput, 56 `uncompressed-png`: pngUncompressedOutput, 57 } 58 59 // paletteAliases normalizes values for the colorscale/palette option 60 var paletteAliases = map[string]string{ 61 `b`: binaryScale, 62 `bin`: binaryScale, 63 `binary`: binaryScale, 64 65 `g`: grayScale, 66 `gr`: grayScale, 67 `gray`: grayScale, 68 69 `m`: magmaScale, 70 `mag`: magmaScale, 71 `magma`: magmaScale, 72 73 `s`: signScale, 74 `sgn`: signScale, 75 `sign`: signScale, 76 77 `py`: viridisScale, 78 `python`: viridisScale, 79 `numpy`: viridisScale, 80 `v`: viridisScale, 81 `vir`: viridisScale, 82 `viridis`: viridisScale, 83 84 `matlab`: parulaScale, 85 `p`: parulaScale, 86 `par`: parulaScale, 87 `parula`: parulaScale, 88 } 89 90 // outputSize is the value type for the resAliases lookup table 91 type outputSize struct { 92 Width int 93 Height int 94 } 95 96 // resAliases normalizes values for option -res 97 var resAliases = map[string]outputSize{ 98 `sq`: {2160, 2160}, 99 `sqr`: {2160, 2160}, 100 `square`: {2160, 2160}, 101 `squared`: {2160, 2160}, 102 103 `4k`: {3840, 2160}, 104 `2160`: {3840, 2160}, 105 `2160p`: {3840, 2160}, 106 `3840`: {3840, 2160}, 107 108 `2.5k`: {2560, 1440}, 109 `1440`: {2560, 1440}, 110 `1440p`: {2560, 1440}, 111 `2560`: {2560, 1440}, 112 113 `2k`: {1920, 1080}, 114 `hd`: {1920, 1080}, 115 `fhd`: {1920, 1080}, 116 `fullhd`: {1920, 1080}, 117 `1080`: {1920, 1080}, 118 `1080p`: {1920, 1080}, 119 `1920`: {1920, 1080}, 120 121 `720`: {1280, 720}, 122 `720p`: {1280, 720}, 123 124 `480p`: {640, 480}, 125 `480`: {640, 480}, 126 127 `2ks`: {1080, 1080}, 128 `4ks`: {2160, 2160}, 129 `2160s`: {2160, 2160}, 130 `1440s`: {1440, 1440}, 131 `1080s`: {1080, 1080}, 132 `720s`: {720, 720}, 133 `480s`: {480, 480}, 134 } 135 136 // config has all parsed cmd-line arguments 137 type config struct { 138 Width int 139 Height int 140 141 XMin float64 142 XMax float64 143 YMin float64 144 YMax float64 145 146 Formula string 147 Output string 148 149 Palette func(float64) color.RGBA 150 Bad color.RGBA 151 152 Integers bool 153 } 154 155 // parseFlags is the constructor for type config 156 func parseFlags(usage string) (config, error) { 157 cfg := config{ 158 Width: 3840, 159 Height: 2160, 160 161 XMin: 0, 162 XMax: 1, 163 YMin: 0, 164 YMax: 1, 165 166 Output: pngOutput, 167 } 168 169 cfg.Output = pngOutput 170 pal := palettes[parulaScale] 171 cfg.Palette = pal.Func 172 cfg.Bad = pal.Bad 173 174 args := os.Args[1:] 175 if len(args) == 0 { 176 fmt.Fprint(os.Stderr, usage) 177 os.Exit(0) 178 179 } 180 181 for _, s := range args { 182 switch s { 183 case `help`, `-h`, `--h`, `-help`, `--help`: 184 fmt.Fprint(os.Stderr, usage) 185 os.Exit(0) 186 } 187 188 err := cfg.handleArg(s) 189 if err != nil { 190 return cfg, err 191 } 192 } 193 194 if cfg.Integers { 195 cfg.XMin = math.Ceil(float64(cfg.XMin)) 196 cfg.XMax = math.Floor(float64(cfg.XMax)) 197 cfg.YMin = math.Ceil(float64(cfg.YMin)) 198 cfg.YMax = math.Floor(float64(cfg.YMax)) 199 } 200 201 if strings.TrimSpace(cfg.Formula) == `` { 202 return cfg, errors.New(`no main formula given`) 203 } 204 return cfg, nil 205 } 206 207 // handleArg parses/uses the cmd-line argument given, except for the help 208 // option and its aliases, which can only be detected separately 209 func (c *config) handleArg(s string) error { 210 switch s { 211 case `int`, `ints`, `integers`: 212 c.Integers = true 213 return nil 214 } 215 216 lcDotless := strings.TrimPrefix(strings.ToLower(s), `.`) 217 if alias, ok := fmtAliases[lcDotless]; ok { 218 c.Output = alias 219 return nil 220 } 221 222 if w, h, ok := parseResolution(s); ok { 223 c.Width = w 224 c.Height = h 225 return nil 226 } 227 228 if colors, ok := paletteAliases[s]; ok { 229 pal := palettes[colors] 230 c.Palette = pal.Func 231 c.Bad = pal.Bad 232 return nil 233 } 234 235 varname, min, max, err := parseDomain(s) 236 if err != nil { 237 return err 238 } 239 240 switch varname { 241 case ``: 242 // no variable name means it's the main formula 243 if c.Formula != `` { 244 const fs = `%q: can't use more than 1 main formula` 245 return fmt.Errorf(fs, s) 246 } 247 c.Formula = s 248 return nil 249 250 case `x`: 251 c.XMin = min 252 c.XMax = max 253 return nil 254 255 case `y`: 256 c.YMin = min 257 c.YMax = max 258 return nil 259 260 case `xy`: 261 c.XMin = min 262 c.XMax = max 263 c.YMin = min 264 c.YMax = max 265 return nil 266 267 default: 268 const fs = "domain variable %q isn't any of `x`, `y`, or `xy`" 269 return fmt.Errorf(fs, varname) 270 } 271 } 272 273 // parseResolution tries to get a width/height resolution out of the 274 // cmd-line argument given to it 275 func parseResolution(s string) (width int, height int, ok bool) { 276 if res, ok := resAliases[s]; ok { 277 return res.Width, res.Height, true 278 } 279 280 i := strings.IndexByte(s, 'x') 281 if i < 0 { 282 return 0, 0, false 283 } 284 285 w, werr := strconv.ParseInt(s[:i], 10, 64) 286 h, herr := strconv.ParseInt(s[i+1:], 10, 64) 287 if werr == nil && herr == nil && w > 0 && h > 0 { 288 return int(w), int(h), true 289 } 290 return 0, 0, false 291 } 292 293 func (c config) IntegerSize() (w, h int) { 294 w = int(math.Abs(c.XMax - c.XMin + 1)) 295 h = int(math.Abs(c.YMax - c.YMin + 1)) 296 return w, h 297 } 298 299 // parseDomain tries to parse domain/variable-range formulas of the form(s) 300 // 301 // - x:=a..b 302 // - y:=a..b 303 // - xy:=a..b 304 // 305 // where a and b represent valid floating-point numbers; when an empty is 306 // returned, it means the strings given wasn't recognized as a variable's 307 // domain, suggesting it may be another option, or the main formula instead 308 func parseDomain(s string) (string, float64, float64, error) { 309 i := strings.Index(s, `:=`) 310 if i < 0 { 311 return ``, 0, 0, nil 312 } 313 314 v := strings.TrimSpace(s[:i]) 315 rng := strings.TrimSpace(s[i+2:]) 316 min, max, err := parseSpan(rng) 317 return v, min, max, err 318 } 319 320 // parseSpan tries to parse a pair of numbers with `..` between them 321 func parseSpan(s string) (float64, float64, error) { 322 pair := strings.Split(s, `..`) 323 if len(pair) != 2 { 324 const fs = "missing `..` in domain-span %s" 325 return 0, 1, fmt.Errorf(fs, s) 326 } 327 328 a, err := strconv.ParseFloat(pair[0], 64) 329 if err != nil { 330 const fs = `can't parse %q in domain-span %s` 331 return 0, 1, fmt.Errorf(fs, pair[0], s) 332 } 333 b, err := strconv.ParseFloat(pair[1], 64) 334 if err != nil { 335 const fs = `can't parse %q in domain-span %s` 336 return 0, 1, fmt.Errorf(fs, pair[1], s) 337 } 338 return a, b, nil 339 } File: fh/config_test.go 1 package main 2 3 import "testing" 4 5 func TestTables(t *testing.T) { 6 for _, kind := range fmtAliases { 7 // check all canonical format names are in the table 8 if _, ok := fmtAliases[kind]; !ok { 9 const fs = `format %q itself isn't in the format-table` 10 t.Fatalf(fs, kind) 11 return 12 } 13 14 if _, ok := encoders[kind]; !ok { 15 const fs = `no encoder for %q` 16 t.Fatalf(fs, kind) 17 return 18 } 19 } 20 21 for _, kind := range paletteAliases { 22 // check all canonical colorscale names are in the table 23 if _, ok := paletteAliases[kind]; !ok { 24 const fs = `format %q itself isn't in the format-table` 25 t.Fatalf(fs, kind) 26 return 27 } 28 29 if _, ok := palettes[kind]; !ok { 30 const fs = `no palette for %q` 31 t.Fatalf(fs, kind) 32 return 33 } 34 } 35 } File: fh/examples.txt 1 # ripples 2 fh xy:=-3..3 'exp(-0.5 * sin(2 * hypot(x - 2, y + 1))) + exp(-0.5 * sin(10 * hypot(x + 2, y - 3.4)))' | si 3 4 # floor lights 5 fh x:=-5..5 y:=1..5 'x.sin.abs / y**1.4' | si 6 7 # beta gradient 8 fh x:=-5..5 y:=1..5 'lbeta(x + 5.1, y + 5.1)' | si 9 10 # hot bars / horizontal bars 11 fh xy:=-5..5 'x.abs + sqrt(abs(sin(2*y)))' | si 12 fh xy:=-5..5 'x.abs + sqrt(abs(sin(2*y)))' | si 13 14 # domain hole 15 fh xy:=-5..5 'log1p(sin(x + y) + (x - y)**2 - 1.5*x + 2.5*y + 1)' | si 16 17 # crazy grids 18 fh xy:=-10..10 'sin(x.sin+y.cos) + cos(sin(x*y)+cos(y*y))' | si 19 20 # panda / smiling ghost 21 fh xy:=-5..5 'log1p(((x - 1)**2 + y*y - 4)*((x + 1)**2 + y*y - 4)*(x*x + (y - sqrt(3))**2 - 4) - 5)' | si 22 23 # lcm 200 24 fh xy:=0.01..199.99 'lcm(x.ceil, y.ceil)' | si 25 26 # light tiles 27 fh 'gauss(2*(sin(50.0*x)*cos(50.0*9/16*y) + 1.0)/2.0)' | si 28 29 # shaky results... at least for me 30 fh 'cos(160*tau*x) + sin(90*tau*y)' | si 31 32 # 90-degree square tiles 33 fh 'sign(cos(160*tau*x) + sin(90*tau*y))' | si File: fh/info.txt 1 fh [options...] [x/y ranges...] formula 2 3 4 Function Heatmapper emits a picture showing a heatmap view of the function 5 f(x, y) implied by the math expression given. Plenty of math functions and 6 constants are available, all their names being lowercase; the syntax is 7 almost identical to Python/JavaScript's math notation, and has no keywords. 8 9 For convenience, you can treat any 1-input func as a fake-property of its 10 only input; you can also pretend all functions are fake-methods, where the 11 1st input comes before the dot preceding the func name, followed by all the 12 other args to it. All values and functions are global: without namespaces 13 of any kind. 14 15 Ranges for variables `x` and `y` are 0 to 1 by default, but you can change 16 them via the special syntax shown on some of the examples below. Using the 17 keyword `int`, `ints`, or `integers` enables integer-mode, where both `x` 18 and `y` values are only sampled as integers: in that case, formula results 19 will be used to fill whole tiles, instead of single pixels. 20 21 By default, output is PNG-encoded using a good tradeoff between encoding 22 speed and final payload size. Output resolutions can be as shown below, or 23 consist of the width, followed by `x`, followed by the height wanted, such 24 as `1024x768`, for example. 25 26 Options have no flags/prefixes, and are accepted in any order. 27 28 29 Options 30 31 resolution resolution 32 33 4k 3840x2160 4ks 2160x2160 34 hd 1920x1080 hds 1080x1080 35 36 2160p 3840x2160 2160s 2160x2160 37 1440p 2560x1440 1440s 1440x1440 38 1080p 1920x1080 1080s 1080x1080 39 720p 1280x720 720s 720x720 40 41 42 output aliases colorscale aliases 43 44 png magma mag, m 45 bmp bitmap parula par, p 46 jpg jpeg viridis vir, v 47 48 49 Concrete Examples 50 51 52 fh 'x/(x+y)' > corner-fan-1.png 53 54 fh 'y/(x+y)' > corner-fan-2.png 55 56 fh 4k x:=-5..5 y:=1..5 'x.sin.abs / y**1.4' > floor-lights.png 57 58 fh vir x:=-5..5 y:=1..5 'lbeta(x + 5.1, y + 5.1)' > beta-gradient.png 59 60 fh mag 4k xy:=0.01..199.99 'lcm(x.ceil, y.ceil)' > lcm-200.png 61 62 fh par 4k xy:=-5..5 'x.abs + sqrt(abs(sin(2*y)))' > bars.png 63 64 fh x:=-1.5..0.5 y:=-1..1 'mandel(16/9*x, y)' > mandelbrot.png 65 66 fh x:=-1.5..0.5 y:=-1..1 'absmandel(16/9*x, y)' > wobbly-mandelbrot.png 67 68 fh 4k 'sign(cos(160*tau*x) + sin(90*tau*y))' > 90-deg-square-tiles.png 69 70 fh xy:=-10..10 'sin(x.sin+y.cos) + cos(sin(x*y)+cos(y*y))' > crazy-grids.png 71 72 fh 'gauss(sin(50*x) * cos(50*9/16*y) + 1)' > light-tiles.png 73 74 fh xy:=-2..3 'sgn(log((x*x-1)*(x-2-y)/(x*x+2+2*y)))' > abstract-shapes.png 75 76 fh xy:=-10..10 square 'sinc(0.55 * hypot(x, y))' > central-ripple.png File: fh/logo.ico <BINARY> File: fh/logo.png <BINARY> File: fh/main.go 1 package main 2 3 import ( 4 "bufio" 5 "fmt" 6 "image" 7 "os" 8 9 _ "embed" 10 ) 11 12 //go:embed info.txt 13 var usage string 14 15 func main() { 16 cfg, err := parseFlags(usage) 17 if err != nil { 18 fmt.Fprintln(os.Stderr, err.Error()) 19 os.Exit(1) 20 } 21 22 if _, ok := encoders[cfg.Output]; !ok { 23 const fs = "unsupported output format %s\n" 24 fmt.Fprintf(os.Stderr, fs, cfg.Output) 25 os.Exit(1) 26 } 27 28 addDetermFuncs() 29 30 if err := run(cfg); err != nil { 31 fmt.Fprintln(os.Stderr, err.Error()) 32 os.Exit(1) 33 } 34 } 35 36 func run(cfg config) error { 37 // f, err := os.Create(`fh.prof`) 38 // if err != nil { 39 // return err 40 // } 41 // defer f.Close() 42 43 // pprof.StartCPUProfile(f) 44 // defer pprof.StopCPUProfile() 45 46 encode, ok := encoders[cfg.Output] 47 if !ok { 48 const fs = `unsupported output format %q` 49 return fmt.Errorf(fs, cfg.Output) 50 } 51 52 if cfg.Integers { 53 w, h := cfg.IntegerSize() 54 cfg.Width = w 55 cfg.Height = h 56 } 57 58 // allow runner to use up to 32 cores 59 r, err := newRunner(cfg, 32) 60 if err != nil { 61 return err 62 } 63 64 res, err := r.Run(cfg) 65 if err != nil { 66 return err 67 } 68 69 img := image.NewRGBA(image.Rectangle{ 70 Min: image.Point{X: 0, Y: 0}, 71 Max: image.Point{X: cfg.Width, Y: cfg.Height}, 72 }) 73 74 w := bufio.NewWriterSize(os.Stdout, 64*1024) 75 defer w.Flush() 76 77 // give back a blank picture if results aren't usable 78 if !res.isValid() { 79 return encode(w, img, cfg) 80 } 81 82 // handle integers-only coordinate-inputs 83 if cfg.Integers { 84 width, height := cfg.IntegerSize() 85 fillExpandedImage(img, res, cfg, width, height) 86 return encode(w, img, cfg) 87 } 88 89 // handle domain-sampled images 90 fillImage(img, res, cfg) 91 return encode(w, img, cfg) 92 } File: fh/output.go 1 package main 2 3 import ( 4 "bufio" 5 "encoding/binary" 6 "image" 7 "image/color" 8 "image/jpeg" 9 "image/png" 10 "math" 11 12 "../../pkg/colorplus" 13 ) 14 15 var ( 16 // red is the invalid color for all palettes with dark/black colors 17 red = color.RGBA{R: 255, G: 0, B: 0, A: 255} 18 19 // black is the invalid color for the more colorful palettes 20 black = color.RGBA{R: 0, G: 0, B: 0, A: 255} 21 ) 22 23 // paletteSettings describes the full behavior of a palette 24 type paletteSettings struct { 25 Func func(float64) color.RGBA 26 Bad color.RGBA 27 } 28 29 // palettes completely describes the behavior of all supported palettes 30 var palettes = map[string]paletteSettings{ 31 grayScale: {gray, red}, 32 magmaScale: {colorplus.Magmify, red}, 33 viridisScale: {colorplus.Viridize, black}, 34 parulaScale: {colorplus.Parulate, black}, 35 binaryScale: {colorBinary, black}, 36 signScale: {colorSign, black}, 37 } 38 39 // gray implements the grayscale coloring option, and is meant to be paired 40 // with a red color for invalid inputs, such as NaNs 41 func gray(x float64) color.RGBA { 42 // restrict input to range 0..1 43 if x < 0 { 44 x = 0 45 } else if x > 1 { 46 x = 1 47 } 48 49 v := uint8(math.Round(255 * x)) 50 return color.RGBA{R: v, G: v, B: v, A: 255} 51 } 52 53 // colorBinary assigns 2 colors, thresholding the number given on 0.5 54 func colorBinary(x float64) color.RGBA { 55 if x < 0.5 { 56 return color.RGBA{R: 234, G: 85, B: 58, A: 255} 57 } 58 return color.RGBA{R: 0, G: 95, B: 0, A: 255} 59 } 60 61 // colorSign assigns 3 colors, depending on the sign of the number given 62 func colorSign(x float64) color.RGBA { 63 if x > 0 { 64 return color.RGBA{R: 0, G: 95, B: 0, A: 255} 65 } 66 if x < 0 { 67 return color.RGBA{R: 234, G: 85, B: 58, A: 255} 68 } 69 return color.RGBA{R: 0, G: 135, B: 215, A: 255} 70 } 71 72 // encoders translates output-format settings into the right func to call 73 var encoders = map[string]func(*bufio.Writer, *image.RGBA, config) error{ 74 pngOutput: encodePNG, 75 bmpOutput: encodeBMP, 76 jpegOutput: encodeJPEG, 77 78 pngFastOutput: encodeFastPNG, 79 pngSmallestOutput: encodeSmallestPNG, 80 pngUncompressedOutput: encodeUncompressedPNG, 81 } 82 83 // fillImage fills/renders an image using previously calculated values 84 func fillImage(img *image.RGBA, res result, cfg config) { 85 k := 0 86 f := cfg.Palette 87 88 for i := 0; i < cfg.Height; i++ { 89 for j := 0; j < cfg.Width; j++ { 90 v := res.Values[k] 91 92 var c color.RGBA 93 if math.IsNaN(v) || math.IsInf(v, 0) { 94 c = cfg.Bad 95 } else { 96 c = f(colorplus.Wrap(v, res.Min, res.Max)) 97 } 98 99 img.SetRGBA(j, i, c) 100 k++ 101 } 102 } 103 } 104 105 // fillExpandedImage is like func fillImage, but rendering stretches what 106 // would otherwise be single pixels into rectangles, representing regions 107 // where the integer-parts of x/y inputs stay the same 108 func fillExpandedImage(img *image.RGBA, res result, cfg config, w, h int) { 109 width := img.Rect.Max.X 110 xmax := float64(img.Rect.Max.X) 111 ymax := float64(img.Rect.Max.Y) 112 113 f := cfg.Palette 114 dx := float64(w) / xmax 115 dy := float64(h) / ymax 116 117 for i := 0; i < cfg.Height; i++ { 118 y := int(dy * float64(i)) 119 for j := 0; j < cfg.Width; j++ { 120 x := int(dx * float64(j)) 121 k := y*width + x 122 v := res.Values[k] 123 124 var c color.RGBA 125 if math.IsNaN(v) || math.IsInf(v, 0) { 126 c = cfg.Bad 127 } else { 128 c = f(colorplus.Wrap(v, res.Min, res.Max)) 129 } 130 img.SetRGBA(j, i, c) 131 } 132 } 133 } 134 135 // encodePNG seems a good default both for its main format (PNG), as well as 136 // its reasonable default tradeoff between speed and output size, compared 137 // to the PNG-encoding alternatives available 138 func encodePNG(w *bufio.Writer, img *image.RGBA, cfg config) error { 139 var enc png.Encoder 140 return enc.Encode(w, img) 141 } 142 143 // encodeFastPNG may not always be much faster than the default PNG encoder 144 func encodeFastPNG(w *bufio.Writer, img *image.RGBA, cfg config) error { 145 var enc png.Encoder 146 enc.CompressionLevel = png.BestSpeed 147 return enc.Encode(w, img) 148 } 149 150 // encodeSmallestPNG is substantially slower than the other PNG encoders 151 func encodeSmallestPNG(w *bufio.Writer, img *image.RGBA, cfg config) error { 152 var enc png.Encoder 153 enc.CompressionLevel = png.BestCompression 154 return enc.Encode(w, img) 155 } 156 157 // encodeUncompressedPNG is mostly to compare it to BMP output: it turns out 158 // BMP is slightly smaller than this 159 func encodeUncompressedPNG(w *bufio.Writer, img *image.RGBA, cfg config) error { 160 var enc png.Encoder 161 enc.CompressionLevel = png.NoCompression 162 return enc.Encode(w, img) 163 } 164 165 // encodeJPEG encodes result at max JPEG setting: this usually results in 166 // highly-detailed results for substantially-fewer bytes, compared to PNG 167 // output 168 func encodeJPEG(w *bufio.Writer, img *image.RGBA, cfg config) error { 169 opt := jpeg.Options{Quality: 100} 170 return jpeg.Encode(w, img, &opt) 171 } 172 173 // https://en.wikipedia.org/wiki/BMP_file_format 174 175 // encodeBMP encodes as BMP/bitmap, a simple uncompressed format, which has 176 // been widely supported for many decades 177 func encodeBMP(w *bufio.Writer, img *image.RGBA, cfg config) error { 178 const ( 179 dibsize = 40 // the DIB is the 2nd header 180 hdrsize = 14 + dibsize // total size of all headers 181 ) 182 imgsize := 3 * cfg.Width * cfg.Height 183 184 w.WriteString(`BM`) 185 binary.Write(w, binary.LittleEndian, uint32(hdrsize+imgsize)) 186 binary.Write(w, binary.LittleEndian, uint16(0)) 187 binary.Write(w, binary.LittleEndian, uint16(0)) 188 binary.Write(w, binary.LittleEndian, uint32(hdrsize)) 189 binary.Write(w, binary.LittleEndian, uint32(dibsize)) 190 binary.Write(w, binary.LittleEndian, int32(cfg.Width)) 191 binary.Write(w, binary.LittleEndian, int32(cfg.Height)) 192 193 // 1 color plane 194 binary.Write(w, binary.LittleEndian, uint16(1)) 195 // 24 bits per pixel 196 binary.Write(w, binary.LittleEndian, uint16(24)) 197 // no compression 198 binary.Write(w, binary.LittleEndian, uint32(0)) 199 // number of bytes for the pixels 200 binary.Write(w, binary.LittleEndian, uint32(imgsize)) 201 // horizontal & vertical pixels/m 202 binary.Write(w, binary.LittleEndian, int32(0)) 203 binary.Write(w, binary.LittleEndian, int32(0)) 204 // 2**n palette colors 205 binary.Write(w, binary.LittleEndian, uint32(0)) 206 // all colors are important 207 binary.Write(w, binary.LittleEndian, uint32(0)) 208 209 stride := img.Stride 210 // rows/lines are apparently stored bottom-to-top 211 for y := cfg.Height - 1; y >= 0; y-- { 212 start := y * stride 213 buf := img.Pix[start : start+stride] 214 215 for len(buf) >= 3 { 216 // color-channel order seems to be BGR, instead of RGB 217 w.WriteByte(buf[2]) 218 w.WriteByte(buf[1]) 219 err := w.WriteByte(buf[0]) 220 if err != nil { 221 // use errors to quit immediately: chances are 222 // the error is the result of a closed-pipe 223 return nil 224 } 225 226 // also skip the alpha channel 227 buf = buf[4:] 228 } 229 } 230 return nil 231 } File: fh/scripts.go 1 package main 2 3 import ( 4 "math" 5 "math/cmplx" 6 "math/rand" 7 "runtime" 8 "sync" 9 "time" 10 11 "../../pkg/fmscripts" 12 "../../pkg/mathplus" 13 ) 14 15 // result has all results, including a summary of the range of values, so the 16 // image renderer can normalize values accordingly 17 type result struct { 18 // Values has all results, which can be normalized into 0..1 using 19 // fields Min and Max. 20 Values []float64 21 22 // Min is the lowest value in Values. 23 Min float64 24 25 // Max is the highest value in Values. 26 Max float64 27 } 28 29 // isValid checks if the result should be a non-blank picture 30 func (r result) isValid() bool { 31 return r.Min <= r.Max && !math.IsInf(r.Min, 0) && !math.IsInf(r.Max, 0) 32 } 33 34 // runner has various twin script-runners, and automatically multicore-splits 35 // the load among tasks along alternating groups of lines, in a striping 36 // manner 37 type runner struct { 38 numTasks int 39 40 // values can have its items updated concurrently, since each vertical 41 // image line is changed by a single task. 42 values []float64 43 44 programs []fmscripts.Program 45 } 46 47 // newRunner is the constructor for type runner 48 func newRunner(cfg config, maxtasks int) (runner, error) { 49 numtasks := runtime.NumCPU() 50 if maxtasks > 0 && numtasks > maxtasks { 51 numtasks = maxtasks 52 } 53 progs := make([]fmscripts.Program, 0, numtasks) 54 55 for i := 0; i < numtasks; i++ { 56 // compiling the same formula multiple times seems wasteful, but 57 // each compilation is very quick; this repetition is necessary 58 // to isolate each task's input variables and pseudo-random state, 59 // anyway 60 p, err := compile(cfg.Formula, cfg, time.Now().UnixNano()) 61 if err != nil { 62 return runner{}, err 63 } 64 progs = append(progs, p) 65 } 66 67 return runner{ 68 numTasks: numtasks, 69 values: make([]float64, cfg.Width*cfg.Height), 70 programs: progs, 71 }, nil 72 } 73 74 // Run is the entry-point func which handles everything from start to finish. 75 func (r *runner) Run(cfg config) (res result, err error) { 76 var wg sync.WaitGroup 77 wg.Add(r.numTasks) 78 79 // fully allocate min/max slices, as appending is concurrently unsafe 80 lmin := make([]float64, r.numTasks) 81 lmax := make([]float64, r.numTasks) 82 83 // run parallel tasks: updating the shared value-slice works, as long 84 // as each process sticks to its own index and output lines 85 for i := 0; i < r.numTasks; i++ { 86 go func(i int) { 87 defer wg.Done() 88 min, max := r.runSlice(i, cfg) 89 lmin[i] = min 90 lmax[i] = max 91 }(i) 92 } 93 wg.Wait() 94 95 // get overall min/max 96 min := math.Inf(+1) 97 max := math.Inf(-1) 98 for i := range lmin { 99 min = math.Min(min, lmin[i]) 100 max = math.Max(max, lmax[i]) 101 } 102 return result{Values: r.values, Min: min, Max: max}, nil 103 } 104 105 // runSlice handles the task a specific core is supposed to handle: call 106 // run instead of this func directly 107 func (r *runner) runSlice(task int, cfg config) (min, max float64) { 108 p := r.programs[task] 109 x, _ := p.Get(`x`) 110 y, _ := p.Get(`y`) 111 zs := r.values 112 113 w := cfg.Width 114 h := cfg.Height 115 n := r.numTasks 116 xmin := math.Min(cfg.XMin, cfg.XMax) 117 ymax := math.Max(cfg.YMax, cfg.YMin) 118 wf := float64(w) 119 120 zmin := math.Inf(+1) 121 zmax := math.Inf(-1) 122 dx := math.Abs(cfg.XMax-cfg.XMin) / float64(cfg.Width-1) 123 dy := math.Abs(cfg.YMax-cfg.YMin) / float64(cfg.Height-1) 124 125 for i := task; i < h; i += n { 126 k := w * i 127 *y = ymax - dy*float64(i) 128 129 for j := 0.0; j < wf; j++ { 130 *x = dx*j + xmin 131 z := p.Run() 132 zs[k] = z 133 k++ 134 135 if !math.IsNaN(z) { 136 zmin = math.Min(zmin, z) 137 zmax = math.Max(zmax, z) 138 } 139 } 140 } 141 return zmin, zmax 142 } 143 144 // compile extends the built-in fast-math script functionality by adding 145 // pseudo-random generators initialized with the seed number given 146 func compile(src string, cfg config, seed int64) (fmscripts.Program, error) { 147 r := rand.New(rand.NewSource(seed)) 148 rand01 := func() float64 { 149 return fmscripts.Random(r) 150 } 151 rint := func(min, max float64) float64 { 152 return fmscripts.RandomInt(r, min, max) 153 } 154 runif := func(min, max float64) float64 { 155 return fmscripts.RandomUnif(r, min, max) 156 } 157 rexp := func(scale float64) float64 { 158 return fmscripts.RandomExp(r, scale) 159 } 160 rnorm := func(mu, sigma float64) float64 { 161 return fmscripts.RandomNorm(r, mu, sigma) 162 } 163 rgamma := func(scale float64) float64 { 164 return fmscripts.RandomGamma(r, scale) 165 } 166 rbeta := func(a, b float64) float64 { 167 return fmscripts.RandomBeta(r, a, b) 168 } 169 170 var c fmscripts.Compiler 171 return c.Compile(src, map[string]any{ 172 `x`: 0.0, 173 `y`: 0.0, 174 175 `w`: float64(cfg.Width), 176 `h`: float64(cfg.Height), 177 `ar`: float64(cfg.Width) / float64(cfg.Height), 178 `aspratio`: float64(cfg.Width) / float64(cfg.Height), 179 180 `rand`: rand01, 181 `rbeta`: rbeta, 182 `rexp`: rexp, 183 `rgamma`: rgamma, 184 `rint`: rint, 185 `rnorm`: rnorm, 186 `runif`: runif, 187 188 `randbeta`: rbeta, 189 `randexp`: rexp, 190 `randexpo`: rexp, 191 `randgam`: rgamma, 192 `randgamma`: rgamma, 193 `randint`: rint, 194 `randnorm`: rnorm, 195 `randunif`: runif, 196 197 `random`: rand01, 198 `rbet`: rbeta, 199 `rgam`: rgamma, 200 `rnd`: rand01, 201 }) 202 } 203 204 // addDetermFuncs does what it says, ensuring these funcs are optimizable when 205 // they're given all-constant expressions as inputs 206 func addDetermFuncs() { 207 fmscripts.DefineDetFuncs(map[string]any{ 208 `ascale`: mathplus.AnchoredScale, 209 `awrap`: mathplus.AnchoredWrap, 210 `choose`: comb, 211 `clamp`: mathplus.Clamp, 212 `comb`: comb, 213 `dbinom`: dbinom, 214 `dnorm`: mathplus.NormalDensity, 215 `epa`: mathplus.Epanechnikov, 216 `epanechnikov`: mathplus.Epanechnikov, 217 `etamag`: etamag, 218 `etamagcap`: etamagcap, 219 `fract`: mathplus.Fract, 220 `gauss`: mathplus.Gauss, 221 `gcd`: gcd, 222 `horner`: mathplus.Polyval, 223 `ieta`: etaimag, 224 `isprime`: isPrime, 225 `lcm`: lcm, 226 `logistic`: mathplus.Logistic, 227 `mageta`: etamag, 228 `magetacap`: etamagcap, 229 `magzeta`: zetamag, 230 `magzetacap`: zetamagcap, 231 `mix`: mathplus.Mix, 232 `perm`: perm, 233 `pbinom`: pbinom, 234 `pnorm`: mathplus.CumulativeNormalDensity, 235 `polyval`: mathplus.Polyval, 236 `reta`: etare, 237 `scale`: mathplus.Scale, 238 `sign`: mathplus.Sign, 239 `sinc`: mathplus.Sinc, 240 `smoothstep`: mathplus.SmoothStep, 241 `step`: mathplus.Step, 242 `tricube`: mathplus.Tricube, 243 `unwrap`: mathplus.Unwrap, 244 `wrap`: mathplus.Wrap, 245 `zetamag`: zetamag, 246 `zetamagcap`: zetamagcap, 247 248 `absmandel`: absmandel, 249 `absmandelcap`: absmandelcap, 250 `itermandel`: itermandel, 251 `itermandelcap`: itermandelcap, 252 `mandel`: itermandel, 253 }) 254 } 255 256 // absmandel returns the abs value of the complex number used in the mandelbrot 257 // recurrence relation; recurrence is automatically truncated to a default 258 // threshold and/or max number of loops 259 func absmandel(x, y float64) float64 { 260 return absmandelcap(x, y, 50) 261 } 262 263 // absmandelcap is like func absmandel, except the cap/threshold is an explicit 264 // parameter 265 func absmandelcap(x, y, threshold float64) float64 { 266 z := 0 + 0i 267 c := complex(x, y) 268 const max = 1000 269 // using the threshold's square to avoid using sqrt 270 ts := threshold * threshold 271 272 for n := 0.0; n < max; n++ { 273 sqmag := real(z)*real(z) + imag(z)*imag(z) 274 if sqmag > ts { 275 return math.Sqrt(sqmag) 276 } 277 z = z*z + c 278 } 279 return cmplx.Abs(z) 280 } 281 282 // itermandel returns the number of iterations used in the mandelbrot 283 // recurrence relation; recurrence is automatically truncated to a default 284 // threshold and/or max number of loops 285 func itermandel(x, y float64) float64 { 286 return itermandelcap(x, y, 50) 287 } 288 289 // itermandelcap returns the number of mandelbrot recurrence iterations like 290 // func itermandel, except the cap/threshold is an explicit parameter 291 func itermandelcap(x, y, threshold float64) float64 { 292 z := 0 + 0i 293 c := complex(x, y) 294 const max = 1000 295 // using the threshold's square to avoid using sqrt 296 ts := threshold * threshold 297 298 for n := 0.0; n < max; n++ { 299 sqmag := real(z)*real(z) + imag(z)*imag(z) 300 if sqmag > ts { 301 return n 302 } 303 z = z*z + c 304 } 305 return max 306 } 307 308 func comb(x, y float64) float64 { 309 return float64(mathplus.Choose(int(x), int(y))) 310 } 311 312 func perm(x, y float64) float64 { 313 return float64(mathplus.Perm(int(x), int(y))) 314 } 315 316 func gcd(x, y float64) float64 { 317 return float64(mathplus.GCD(int64(x), int64(y))) 318 } 319 320 func lcm(x, y float64) float64 { 321 return float64(mathplus.LCM(int64(x), int64(y))) 322 } 323 324 func dbinom(x, n, p float64) float64 { 325 return mathplus.BinomialMass(int(x), int(n), p) 326 } 327 328 func pbinom(x, n, p float64) float64 { 329 return mathplus.CumulativeBinomialDensity(int(x), int(n), p) 330 } 331 332 func isPrime(x float64) float64 { 333 if mathplus.IsPrime(int64(x)) { 334 return 1 335 } 336 return 0 337 } 338 339 const ( 340 // etaTrunc is when the summation for the eta funcs stops by default 341 etaTrunc = 50 342 343 // zetaTrunc is when the summation for the zeta funcs stops by default 344 zetaTrunc = 50 345 ) 346 347 // etamag call func etamagcap with a default truncation 348 func etamag(x, y float64) float64 { 349 return cmplx.Abs(eta(complex(x, y))) 350 } 351 352 // etamagcap is the real-valued magnitude of the truncated approx. of func eta 353 func etamagcap(x, y float64, max float64) float64 { 354 return cmplx.Abs(etacap(complex(x, y), int(max))) 355 } 356 357 // etare is the real part of the truncated approx. of func eta 358 func etare(x, y float64) float64 { return real(eta(complex(x, y))) } 359 360 // etaimag is the imaginary part of the truncated approx. of func eta 361 func etaimag(x, y float64) float64 { return imag(eta(complex(x, y))) } 362 363 // eta approximates the dirichlet eta function by truncation 364 func eta(x complex128) complex128 { return etacap(x, etaTrunc) } 365 366 // etacap accepts a cap/max iteration value for the eta func truncation 367 func etacap(x complex128, max int) complex128 { 368 y := 0 + 0i 369 v := 1 + 0i 370 sign := 1 + 0i 371 372 for n := 1; n <= max; n++ { 373 y += sign * v 374 sign *= -1 375 v /= x 376 } 377 return y 378 } 379 380 // zetamag call func zetamagcap with a default truncation 381 func zetamag(x, y float64) float64 { 382 return cmplx.Abs(zetacap(complex(x, y), zetaTrunc)) 383 } 384 385 // etamagcap is the real-valued magnitude of the truncated approx. of func eta 386 func zetamagcap(x, y float64, max float64) float64 { 387 return cmplx.Abs(etacap(complex(x, y), int(max))) 388 } 389 390 // zetacap accepts a cap/max iteration value for the zeta func truncation 391 func zetacap(x complex128, max int) complex128 { 392 y := 0 + 0i 393 v := 1 + 0i 394 395 for n := 1; n <= max; n++ { 396 y += v 397 v /= x 398 } 399 return y 400 } File: fh/winapp_amd64.syso <BINARY> File: fh/winapp.rc 1 // https://docs.microsoft.com/en-us/windows/win32/menurc/versioninfo-resource 2 // windres -o winapp_amd64.syso winapp.rc 3 4 IDI_ICON1 ICON "logo.ico"