You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
343 lines
6.9 KiB
Go
343 lines
6.9 KiB
Go
/*
|
|
tdiv: terminal dithered iamge viewer
|
|
An easy way to view images via unicode text in your terminal. copy it, paste
|
|
it, pipe it, etc. Great fun for all.
|
|
|
|
tdiv is (c) 2020 brian evans < sloum AT rawtext.club >, all rights reserved
|
|
and is made available under the terms of the Floodgap Free Software License
|
|
a copy of which is included with this software (and must be included with
|
|
any distribution or derivation thereof). The license can also be referenced
|
|
at: https://www.floodgap.com/software/ffsl/license.html
|
|
*/
|
|
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"image"
|
|
"image/gif"
|
|
"image/jpeg"
|
|
"image/png"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"strconv"
|
|
)
|
|
|
|
func termSize() (int, int, error) {
|
|
cmd := exec.Command("stty", "size")
|
|
cmd.Stdin = os.Stdin
|
|
res, err := cmd.Output()
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
var h, w int
|
|
_, _ = fmt.Sscan(string(res), &h, &w)
|
|
return h, w, nil
|
|
}
|
|
|
|
func getBraille(pattern string) (rune, error) {
|
|
switch pattern {
|
|
case "000000":
|
|
return ' ', nil
|
|
case "100000":
|
|
return '⠁', nil
|
|
case "001000":
|
|
return '⠂', nil
|
|
case "101000":
|
|
return '⠃', nil
|
|
case "000010":
|
|
return '⠄', nil
|
|
case "100010":
|
|
return '⠅', nil
|
|
case "001010":
|
|
return '⠆', nil
|
|
case "101010":
|
|
return '⠇', nil
|
|
case "010000":
|
|
return '⠈', nil
|
|
case "110000":
|
|
return '⠉', nil
|
|
case "011000":
|
|
return '⠊', nil
|
|
case "111000":
|
|
return '⠋', nil
|
|
case "010010":
|
|
return '⠌', nil
|
|
case "110010":
|
|
return '⠍', nil
|
|
case "011010":
|
|
return '⠎', nil
|
|
case "111010":
|
|
return '⠏', nil
|
|
case "000100":
|
|
return '⠐', nil
|
|
case "100100":
|
|
return '⠑', nil
|
|
case "001100":
|
|
return '⠒', nil
|
|
case "101100":
|
|
return '⠓', nil
|
|
case "000110":
|
|
return '⠔', nil
|
|
case "100110":
|
|
return '⠕', nil
|
|
case "001110":
|
|
return '⠖', nil
|
|
case "101110":
|
|
return '⠗', nil
|
|
case "010100":
|
|
return '⠘', nil
|
|
case "110100":
|
|
return '⠙', nil
|
|
case "011100":
|
|
return '⠚', nil
|
|
case "111100":
|
|
return '⠛', nil
|
|
case "010110":
|
|
return '⠜', nil
|
|
case "110110":
|
|
return '⠝', nil
|
|
case "011110":
|
|
return '⠞', nil
|
|
case "111110":
|
|
return '⠟', nil
|
|
case "000001":
|
|
return '⠠', nil
|
|
case "100001":
|
|
return '⠡', nil
|
|
case "001001":
|
|
return '⠢', nil
|
|
case "101001":
|
|
return '⠣', nil
|
|
case "000011":
|
|
return '⠤', nil
|
|
case "100011":
|
|
return '⠥', nil
|
|
case "001011":
|
|
return '⠦', nil
|
|
case "101011":
|
|
return '⠧', nil
|
|
case "010001":
|
|
return '⠨', nil
|
|
case "110001":
|
|
return '⠩', nil
|
|
case "011001":
|
|
return '⠪', nil
|
|
case "111001":
|
|
return '⠫', nil
|
|
case "010011":
|
|
return '⠬', nil
|
|
case "110011":
|
|
return '⠭', nil
|
|
case "011011":
|
|
return '⠮', nil
|
|
case "111011":
|
|
return '⠯', nil
|
|
case "000101":
|
|
return '⠰', nil
|
|
case "100101":
|
|
return '⠱', nil
|
|
case "001101":
|
|
return '⠲', nil
|
|
case "101101":
|
|
return '⠳', nil
|
|
case "000111":
|
|
return '⠴', nil
|
|
case "100111":
|
|
return '⠵', nil
|
|
case "001111":
|
|
return '⠶', nil
|
|
case "101111":
|
|
return '⠷', nil
|
|
case "010101":
|
|
return '⠸', nil
|
|
case "110101":
|
|
return '⠹', nil
|
|
case "011101":
|
|
return '⠺', nil
|
|
case "111101":
|
|
return '⠻', nil
|
|
case "010111":
|
|
return '⠼', nil
|
|
case "110111":
|
|
return '⠽', nil
|
|
case "011111":
|
|
return '⠾', nil
|
|
case "111111":
|
|
return '⠿', nil
|
|
default:
|
|
return '!', fmt.Errorf("Invalid character entry")
|
|
}
|
|
}
|
|
|
|
// scaleImage loads and scales an image and returns a 2d pixel-int slice
|
|
//
|
|
// Adapted from:
|
|
// http://tech-algorithm.com/articles/nearest-neighbor-image-scaling/
|
|
func scaleImage(file io.Reader, newWidth int) (int, int, [][]int, error) {
|
|
img, _, err := image.Decode(file)
|
|
if err != nil {
|
|
return 0, 0, nil, err
|
|
}
|
|
|
|
bounds := img.Bounds()
|
|
width, height := bounds.Max.X, bounds.Max.Y
|
|
newHeight := int(float64(newWidth) * (float64(height) / float64(width)))
|
|
|
|
out := make([][]int, newHeight)
|
|
for i := range out {
|
|
out[i] = make([]int, newWidth)
|
|
}
|
|
|
|
xRatio := float64(width) / float64(newWidth)
|
|
yRatio := float64(height) / float64(newHeight)
|
|
var px, py int
|
|
for i := 0; i < newHeight; i++ {
|
|
for j := 0; j < newWidth; j++ {
|
|
px = int(float64(j) * xRatio)
|
|
py = int(float64(i) * yRatio)
|
|
out[i][j] = rgbaToGray(img.At(px, py).RGBA())
|
|
}
|
|
}
|
|
return newWidth, newHeight, out, nil
|
|
}
|
|
|
|
// Get the bi-dimensional pixel array
|
|
func getPixels(file io.Reader) (int, int, [][]int, error) {
|
|
img, _, err := image.Decode(file)
|
|
if err != nil {
|
|
return 0, 0, nil, err
|
|
}
|
|
|
|
bounds := img.Bounds()
|
|
width, height := bounds.Max.X, bounds.Max.Y
|
|
|
|
var pixels [][]int
|
|
for y := 0; y < height; y++ {
|
|
var row []int
|
|
for x := 0; x < width; x++ {
|
|
row = append(row, rgbaToGray(img.At(x, y).RGBA()))
|
|
}
|
|
pixels = append(pixels, row)
|
|
}
|
|
|
|
return width, height, pixels, nil
|
|
}
|
|
|
|
func errorDither(w, h int, p [][]int) [][]int {
|
|
mv := [4][2]int{
|
|
[2]int{0, 1},
|
|
[2]int{1, 1},
|
|
[2]int{1, 0},
|
|
[2]int{1, -1},
|
|
}
|
|
per := [4]float64{0.4375, 0.0625, 0.3125, 0.1875}
|
|
var res, diff int
|
|
for y := 0; y < h; y++ {
|
|
for x := 0; x < w; x++ {
|
|
cur := p[y][x]
|
|
if cur > 128 {
|
|
res = 1
|
|
diff = -(255 - cur)
|
|
} else {
|
|
res = 0
|
|
diff = cur // TODO see why this was abs() in the py version
|
|
}
|
|
for i, v := range mv {
|
|
if y+v[0] >= h || x+v[1] >= w || x+v[1] <= 0 {
|
|
continue
|
|
}
|
|
px := p[y+v[0]][x+v[1]]
|
|
px = int(float64(diff)*per[i] + float64(px))
|
|
if px < 0 {
|
|
px = 0
|
|
} else if px > 255 {
|
|
px = 255
|
|
}
|
|
p[y+v[0]][x+v[1]] = px
|
|
p[y][x] = res
|
|
}
|
|
}
|
|
}
|
|
return p
|
|
}
|
|
|
|
func toBraille(p [][]int) []rune {
|
|
w := len(p[0]) // TODO this is unsafe
|
|
h := len(p)
|
|
rows := h / 3
|
|
cols := w / 2
|
|
out := make([]rune, rows*(cols+1))
|
|
counter := 0
|
|
for y := 0; y < h-3; y += 4 {
|
|
for x := 0; x < w-1; x += 2 {
|
|
str := fmt.Sprintf(
|
|
"%d%d%d%d%d%d",
|
|
p[y][x], p[y][x+1],
|
|
p[y+1][x], p[y+1][x+1],
|
|
p[y+2][x], p[y+2][x+1])
|
|
b, err := getBraille(str)
|
|
if err != nil {
|
|
out[counter] = ' '
|
|
} else {
|
|
out[counter] = b
|
|
}
|
|
counter++
|
|
}
|
|
out[counter] = '\n'
|
|
counter++
|
|
}
|
|
return out
|
|
}
|
|
|
|
func rgbaToGray(r uint32, g uint32, b uint32, a uint32) int {
|
|
rf := float64(r/257) * 0.92126
|
|
gf := float64(g/257) * 0.97152
|
|
bf := float64(b/257) * 0.90722
|
|
grey := int((rf + gf + bf) / 3)
|
|
return grey
|
|
}
|
|
|
|
func main() {
|
|
image.RegisterFormat("jpeg", "jpeg", jpeg.Decode, jpeg.DecodeConfig)
|
|
image.RegisterFormat("png", "png", png.Decode, png.DecodeConfig)
|
|
image.RegisterFormat("gif", "gif", gif.Decode, gif.DecodeConfig)
|
|
if len(os.Args) < 2 {
|
|
fmt.Println("Image path is required")
|
|
fmt.Println("\nUsage:\n\ntdiv image_path [px_width]")
|
|
os.Exit(1)
|
|
}
|
|
imgPath := os.Args[1]
|
|
f, err := os.Open(imgPath)
|
|
if err != nil {
|
|
fmt.Println(err.Error())
|
|
os.Exit(1)
|
|
}
|
|
defer f.Close()
|
|
var w, h, width int
|
|
var p [][]int
|
|
if len(os.Args) == 3 {
|
|
width, err = strconv.Atoi(os.Args[2])
|
|
if err != nil {
|
|
fmt.Println("Invalid width argument")
|
|
os.Exit(1)
|
|
}
|
|
} else {
|
|
_, w, err = termSize()
|
|
if err != nil {
|
|
width = 120
|
|
} else {
|
|
width = w * 2
|
|
}
|
|
}
|
|
w, h, p, err = scaleImage(f, width)
|
|
|
|
if err != nil {
|
|
fmt.Println(err.Error())
|
|
os.Exit(1)
|
|
}
|
|
px := errorDither(w, h, p)
|
|
b := toBraille(px)
|
|
fmt.Print(string(b))
|
|
}
|