File: vip/config.go 1 package main 2 3 import ( 4 "flag" 5 "fmt" 6 ) 7 8 // config is the parsed cmd-line options given to the app 9 type config struct { 10 Width int 11 Filenames []string 12 } 13 14 const ( 15 widthUsage = "output width in character blocks/rectangles" 16 ) 17 18 // parseFlags is the constructor for type config 19 func parseFlags(usage string) config { 20 flag.Usage = func() { 21 fmt.Fprintf(flag.CommandLine.Output(), "%s\n\nOptions\n\n", usage) 22 flag.PrintDefaults() 23 } 24 25 cfg := config{Width: 80} 26 27 flag.IntVar(&cfg.Width, "w", cfg.Width, "alias for option -width") 28 flag.IntVar(&cfg.Width, "width", cfg.Width, widthUsage) 29 flag.Parse() 30 31 cfg.Filenames = flag.Args() 32 return cfg 33 } File: vip/go.mod 1 module vip 2 3 go 1.18 4 5 require golang.org/x/image v0.7.0 File: vip/go.sum 1 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 2 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 3 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 4 golang.org/x/image v0.7.0 h1:gzS29xtG1J5ybQlv0PuyfE3nmc6R4qB73m6LUUmvFuw= 5 golang.org/x/image v0.7.0/go.mod h1:nd/q4ef1AKKYl/4kft7g+6UyGbdiqWqTP1ZAbRoV7Rg= 6 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 7 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 8 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 9 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 10 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 11 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 12 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 13 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 14 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 15 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 16 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 17 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 18 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 19 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 20 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 21 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 22 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 23 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 24 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 25 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 26 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 27 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 28 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 29 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 30 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 31 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 32 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 33 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= File: vip/images.go 1 package main 2 3 import ( 4 "bytes" 5 "image" 6 ) 7 8 // picture is a true-color picture with size info, and isn't called image 9 // because this app uses package image from the stdlib, taking away that name 10 type picture struct { 11 // Width is the picture's width in pixels 12 Width uint32 13 14 // Height is the picture's height in pixels 15 Height uint32 16 17 // Pixels is the repeating RGB byte-sequence of length 3*Width*Height 18 Pixels []byte 19 } 20 21 // ensureRGBA turns generic images into RGBA images specifically, since the 22 // image display logic depends on that specific format 23 func ensureRGBA(img image.Image) *image.RGBA { 24 rgba, ok := img.(*image.RGBA) 25 if ok { 26 // it's already RGBA 27 return rgba 28 } 29 30 bnds := img.Bounds() 31 rgba = image.NewRGBA(bnds) 32 for row := bnds.Min.Y; row < bnds.Max.Y; row++ { 33 for col := bnds.Min.X; col < bnds.Max.X; col++ { 34 rgba.Set(col, row, img.At(col, row)) 35 } 36 } 37 return rgba 38 } 39 40 // smoothShrink shrinks the picture given by averaging all pixels matching each 41 // pixel in the shrunk image, resulting in a smooth/low-noise result 42 func smoothShrink(in picture, w, h int) picture { 43 if w < 1 || h < 1 { 44 return picture{} 45 } 46 if int(in.Width) <= w && int(in.Height) <= h { 47 // input pic is smaller than requested output, so there's 48 // no need to shrink it 49 return in 50 } 51 52 // sum all RGB values for each output tile's `catchment region`, as 53 // well as their hit-counts, to later calculate arithmetic averages 54 55 hits := make([]float64, w*h) 56 sums := make([]float64, 3*w*h) 57 58 xincr := float64(w) / float64(in.Width) 59 yincr := float64(h) / float64(in.Height) 60 61 for row := 0; row < int(in.Height); row++ { 62 for col := 0; col < int(in.Width); col++ { 63 x := int(xincr * float64(col)) 64 y := int(yincr * float64(row)) 65 66 dst := (w*y + x) 67 // calculate pixel-areas from the source would arguably be more 68 // efficient, but counting hits is simple and it works; later 69 // note: that approach leaves a few subtle periodic bands, so 70 // keep this naive approach for now 71 hits[dst]++ 72 73 dst *= 3 74 src := 3 * (int(in.Width)*row + col) 75 // add normalized values (range 0..1) for each color component 76 sums[dst+0] += float64(in.Pixels[src+0]) / 255 77 sums[dst+1] += float64(in.Pixels[src+1]) / 255 78 sums[dst+2] += float64(in.Pixels[src+2]) / 255 79 } 80 } 81 82 // shrunk is a picture representing the output tiles, each pixel being 83 // the average of all source pixels from their corresponding region from 84 // the original picture 85 shrunk := picture{ 86 Width: uint32(w), 87 Height: uint32(h), 88 Pixels: make([]byte, 0, 3*w*h), 89 } 90 for i := 0; i < 3*w*h; i++ { 91 avg := 255.0 * sums[i] / hits[i/3] 92 // avg := 255.0 * sums[i] * (xincr * yincr) 93 shrunk.Pixels = append(shrunk.Pixels, byte(avg)) 94 } 95 return shrunk 96 } 97 98 // noisyShrink is a simpler way to shrink down a pic: the name comes from the 99 // side-effect of sampling a single pixel from a whole region, resulting in 100 // high-noise fast-changing pixel colors in the result image 101 func noisyShrink(in picture, w, h int) picture { 102 if w < 1 || h < 1 { 103 return picture{} 104 } 105 if int(in.Width) <= w && int(in.Height) <= h { 106 return in 107 } 108 109 shrunk := picture{ 110 Width: uint32(w), 111 Height: uint32(h), 112 Pixels: make([]byte, 3*w*h), 113 } 114 115 xincr := float64(in.Width) / float64(w) 116 yincr := float64(in.Height) / float64(h) 117 118 for row := 0; row < h; row++ { 119 for col := 0; col < w; col++ { 120 x := int(xincr * float64(col)) 121 y := int(yincr * float64(row)) 122 123 src := 3 * (int(in.Width)*y + x) 124 dst := 3 * (w*row + col) 125 shrunk.Pixels[dst+0] = in.Pixels[src+0] 126 shrunk.Pixels[dst+1] = in.Pixels[src+1] 127 shrunk.Pixels[dst+2] = in.Pixels[src+2] 128 } 129 } 130 return shrunk 131 } 132 133 // guessInputType tries to guess the image format using the first few bytes 134 // bytes read from its data stream 135 func guessImageType(start []byte) (string, bool) { 136 if bytes.HasPrefix(start, []byte{0x89, 'P', 'N', 'G'}) { 137 return "png", true 138 } 139 if bytes.HasPrefix(start, []byte{0xff, 0xd8, 0xff}) { 140 return "jpeg", true 141 } 142 if seemsWEBP(start) { 143 return "webp", true 144 } 145 if bytes.HasPrefix(start, []byte{'B', 'M'}) { 146 return "bmp", true 147 } 148 if bytes.HasPrefix(start, []byte{'I', 'I', '*', 0}) { 149 return "tiff", true 150 } 151 if bytes.HasPrefix(start, []byte{'M', 'M', 0, '*'}) { 152 return "tiff", true 153 } 154 if bytes.HasPrefix(start, []byte{'G', 'I', 'F', '8', '7', 'a'}) { 155 return "gif", true 156 } 157 if bytes.HasPrefix(start, []byte{'G', 'I', 'F', '8', '9', 'a'}) { 158 return "gif", true 159 } 160 return "", false 161 } 162 163 // seemsWEBP simplifies control-flow for func guessImageType 164 func seemsWEBP(start []byte) bool { 165 if !bytes.HasPrefix(start, []byte{'R', 'I', 'F', 'F'}) { 166 return false 167 } 168 if len(start) >= 12 { 169 return bytes.HasPrefix(start[8:], []byte{'W', 'E', 'B', 'P'}) 170 } 171 return false 172 } File: vip/info.txt 1 vip [options] [filenames...] 2 3 VIew Pictures on any truecolor terminal emulator / cmd-line: you may want to 4 pipe its results to `less -RS`, unless pics are small enough to fit in your 5 current window size. 6 7 Supported input formats 8 9 - PNG (stills only) 10 - JPEG 11 - GIF (stills only) 12 - BMP 13 - TIFF 14 - WEBP 15 16 If your terminal emulator supports inline base64-encoded images, run this app 17 with option -to=direct for best results. File: vip/main.go 1 package main 2 3 import ( 4 "bufio" 5 "bytes" 6 "errors" 7 "fmt" 8 "image" 9 "image/gif" 10 "image/jpeg" 11 "image/png" 12 "io" 13 "math" 14 "os" 15 16 "golang.org/x/image/bmp" 17 "golang.org/x/image/tiff" 18 "golang.org/x/image/webp" 19 20 _ "embed" 21 ) 22 23 // errDoneWriting is a dummy error to signal the app should quit 24 var errDoneWriting = errors.New("no more output") 25 26 //go:embed info.txt 27 var usage string 28 29 // decoders dispatches image-format names to decoder funcs 30 var decoders = map[string]func(io.Reader) (image.Image, error){ 31 "png": png.Decode, 32 "jpeg": jpeg.Decode, 33 "webp": webp.Decode, 34 "bmp": bmp.Decode, 35 "tiff": tiff.Decode, 36 "gif": gif.Decode, 37 } 38 39 func main() { 40 // avoid annoying unused-func warnings 41 _ = noisyShrink 42 43 if run() > 0 { 44 os.Exit(1) 45 } 46 } 47 48 // run returns the number of errors occurred 49 func run() int { 50 // f, _ := os.Create("vip.prof") 51 // defer f.Close() 52 // pprof.StartCPUProfile(f) 53 // defer pprof.StopCPUProfile() 54 55 cfg := parseFlags(usage) 56 w := bufio.NewWriter(os.Stdout) 57 defer w.Flush() 58 59 // performance-profiling shows no real benefit in avoiding 60 // the likes of fmt.Fprint, since the overwhelming share of 61 // time is spent decoding inputs, especially when they're 62 // beyond a few megabytes 63 64 errors := 0 65 for i, fname := range cfg.Filenames { 66 if i > 0 { 67 fmt.Fprint(w, "\n\n") 68 } 69 if len(cfg.Filenames) > 1 { 70 fmt.Fprintf(w, "%s\n\n", fname) 71 } 72 73 err := handleFile(w, fname, cfg) 74 if err != nil { 75 w.Flush() 76 fmt.Fprintf(os.Stderr, "%s: %s\n", fname, err.Error()) 77 errors++ 78 } 79 } 80 81 if len(cfg.Filenames) == 0 { 82 err := handleReader(w, os.Stdin, cfg) 83 if err != nil { 84 w.Flush() 85 fmt.Fprintln(os.Stderr, err.Error()) 86 errors++ 87 } 88 } 89 90 return errors 91 } 92 93 // handleFile defer-closes files immediately after use for func run 94 func handleFile(w *bufio.Writer, fname string, cfg config) error { 95 f, err := os.Open(fname) 96 if err != nil { 97 return err 98 } 99 defer f.Close() 100 return handleReader(w, f, cfg) 101 } 102 103 // handleReader reads the input image, transforms/shrinks it, and show it 104 // as ANSI-colored blocks on the terminal 105 func handleReader(w *bufio.Writer, r io.Reader, cfg config) error { 106 defer w.Flush() 107 108 var start [32]byte 109 n, err := r.Read(start[:]) 110 if err != nil { 111 return err 112 } 113 114 kind, ok := guessImageType(start[:n]) 115 if !ok { 116 return errors.New("unsupported image data") 117 } 118 119 dec, ok := decoders[kind] 120 if !ok { 121 return errors.New("unsupported image data") 122 } 123 124 // decode input data 125 img, err := dec(io.MultiReader(bytes.NewReader(start[:n]), r)) 126 if err != nil { 127 return err 128 } 129 130 data := ensureRGBA(img) 131 pic := picture{ 132 Width: uint32(img.Bounds().Dx()), 133 Height: uint32(img.Bounds().Dy()), 134 } 135 pic.Pixels = make([]byte, 0, 3*pic.Width*pic.Height) 136 // copy all pixels, ignoring their alpha values 137 for i := 0; i < len(data.Pix); i += 4 { 138 pic.Pixels = append(pic.Pixels, data.Pix[i+0]) 139 pic.Pixels = append(pic.Pixels, data.Pix[i+1]) 140 pic.Pixels = append(pic.Pixels, data.Pix[i+2]) 141 } 142 143 // find output height by keeping the aspect-ratio of the original pic 144 ar := float64(pic.Width) / float64(pic.Height) 145 h := int(math.Ceil(float64(cfg.Width) / ar)) 146 147 // show a shrunk copy of the pic 148 err = show(w, smoothShrink(pic, int(cfg.Width), h)) 149 if err == errDoneWriting { 150 return nil 151 } 152 return err 153 } File: vip/output.go 1 package main 2 3 import "bufio" 4 5 // show shows a picture as ANSI-styled full-color terminal graphics 6 func show(w *bufio.Writer, pic picture) error { 7 if pic.Width < 1 || pic.Height < 1 { 8 return nil 9 } 10 11 rowWidth := 3 * pic.Width 12 top := make([]byte, rowWidth) 13 bottom := make([]byte, rowWidth) 14 15 pixels := pic.Pixels 16 // loop on pairs of data rows 17 for len(pixels) >= 2*int(rowWidth) { 18 copy(top, pixels[:rowWidth]) 19 pixels = pixels[rowWidth:] 20 copy(bottom, pixels[:rowWidth]) 21 pixels = pixels[rowWidth:] 22 23 err := writeLine(w, top, bottom) 24 if err != nil { 25 return err 26 } 27 } 28 29 // don't forget the last row for pictures with an odd number of rows 30 if len(pixels) >= int(rowWidth) { 31 // use top/last row as is 32 copy(top, pixels[:rowWidth]) 33 // make bottom row all black 34 for i := 0; i < len(bottom); i++ { 35 bottom[i] = 0 36 } 37 return writeLine(w, top, bottom) 38 } 39 return nil 40 } 41 42 // writeLine handles emitting a row/line as a combination of 2 adjacent 43 // image rows/lines, combining them pair-wise into ANSI-styled cells 44 func writeLine(w *bufio.Writer, top, bottom []byte) error { 45 for len(top) >= 3 && len(bottom) >= 3 { 46 writeDoublePixel(w, 47 top[0], top[1], top[2], 48 bottom[0], bottom[1], bottom[2], 49 ) 50 top = top[3:] 51 bottom = bottom[3:] 52 } 53 54 n, err := w.WriteString("\x1b[0m\n") 55 if n < 1 || err != nil { 56 return errDoneWriting 57 } 58 return nil 59 } 60 61 // writeDoublePixel handles combining 2 pixels on top of each other as a 62 // single cell, using both background and foreground styles to combine them 63 func writeDoublePixel(w *bufio.Writer, a, b, c, d, e, f byte) { 64 // fmt.Fprintf(w, "\x1b[48;2;%d;%d;%dm", a, b, c) 65 // fmt.Fprintf(w, "\x1b[38;2;%d;%d;%dm▄", d, e, f) 66 67 w.WriteString("\x1b[48;2;") 68 writeByteDigits(w, a) 69 w.WriteByte(';') 70 writeByteDigits(w, b) 71 w.WriteByte(';') 72 writeByteDigits(w, c) 73 w.WriteByte('m') 74 75 w.WriteString("\x1b[38;2;") 76 writeByteDigits(w, d) 77 w.WriteByte(';') 78 writeByteDigits(w, e) 79 w.WriteByte(';') 80 writeByteDigits(w, f) 81 w.WriteString("m▄") 82 } 83 84 // writeByteDigits emits the unpadded decimal digits of the byte given 85 func writeByteDigits(w *bufio.Writer, b byte) { 86 if b >= 100 { 87 w.WriteByte(b/100 + '0') 88 b %= 100 89 w.WriteByte(b/10 + '0') 90 w.WriteByte(b%10 + '0') 91 return 92 } 93 94 if b >= 10 { 95 w.WriteByte(b/10 + '0') 96 w.WriteByte(b%10 + '0') 97 return 98 } 99 100 w.WriteByte(b + '0') 101 }