From e384a47e508a114bdfdda9b5356f4887ba853335 Mon Sep 17 00:00:00 2001 From: Rose Hogenson Date: Fri, 12 Jun 2026 21:06:51 -0700 Subject: Move icat into a subdirectory --- bench_test.go | 20 ---- icat.go | 324 ----------------------------------------------------- icat/bench_test.go | 20 ++++ icat/icat.go | 324 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 344 insertions(+), 344 deletions(-) delete mode 100644 bench_test.go delete mode 100644 icat.go create mode 100644 icat/bench_test.go create mode 100644 icat/icat.go diff --git a/bench_test.go b/bench_test.go deleted file mode 100644 index 1e79a10..0000000 --- a/bench_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package main - -import ( - "cmp" - "math/rand/v2" - "testing" -) - -func BenchmarkQuickSelect(b *testing.B) { - rng := rand.New(rand.NewPCG(0, 0)) - myTestCase := make([]int, 3840*2160) - for b.Loop() { - b.StopTimer() - for i := range myTestCase { - myTestCase[i] = rng.Int() - } - b.StartTimer() - quickSelect(myTestCase, len(myTestCase)/2, cmp.Compare) - } -} diff --git a/icat.go b/icat.go deleted file mode 100644 index 2b69170..0000000 --- a/icat.go +++ /dev/null @@ -1,324 +0,0 @@ -// The icat command displays an image to the terminal using block characters. -package main - -import ( - "errors" - "flag" - "fmt" - "image" - "image/color" - _ "image/gif" - _ "image/jpeg" - _ "image/png" - "math" - "os" - "slices" - - _ "golang.org/x/image/bmp" - "golang.org/x/image/draw" - _ "golang.org/x/image/tiff" - _ "golang.org/x/image/webp" - "golang.org/x/sys/unix" - "roseh.moe/pkg/sixel" -) - -var ( - x = flag.Int("x", 0, "set image width in columns") - y = flag.Int("y", 0, "set image height in rows") - m = flagPrintMode(flag.CommandLine, "m", modeBlock24, "one of 'block', 'block24', or 'sixel'") -) - -type printMode int - -const ( - modeInvalid printMode = iota - modeBlock - modeBlock24 - modeSixel -) - -type printModeValue printMode - -func (m *printModeValue) String() string { - switch printMode(*m) { - case modeBlock: - return "block" - case modeBlock24: - return "block24" - case modeSixel: - return "sixel" - default: - return "invalid" - } -} - -func (m *printModeValue) Set(s string) error { - var mode printMode - switch s { - case "block": - mode = modeBlock - case "block24": - mode = modeBlock24 - case "sixel": - mode = modeSixel - default: - return fmt.Errorf("bad mode type %q, should be one of 'block', 'block24', or 'sixel'", s) - } - *m = printModeValue(mode) - return nil -} - -func flagPrintMode(fs *flag.FlagSet, name string, value printMode, usage string) *printMode { - fs.Var((*printModeValue)(&value), name, usage) - return &value -} - -func divRound[N ~int64 | ~int](n, d N) N { - return (n + d/2) / d -} - -func load(filename string) (image.Image, error) { - file := os.Stdin - if filename != "-" { - var err error - file, err = os.Open(filename) - if err != nil { - return nil, err - } - defer file.Close() - } - - img, _, err := image.Decode(file) - if err != nil { - return nil, fmt.Errorf("decode %q: %s", filename, err) - } - - return img, nil -} - -func sign(n int) float64 { - if n < 0 { - return -1 - } - if n > 0 { - return 1 - } - return 0 -} - -func floydRivest[S ~[]E, E any](array S, left, right, k int, cmp func(E, E) int) { - for right > left { - if right-left > 600 { - n := right - left + 1 - i := k - left + 1 - z := math.Log(float64(n)) - s := .5 * math.Exp(2*z/3) - sd := .5 * math.Sqrt(z*s*(float64(n)-s)/float64(n)) * sign(i-n/2) - newLeft := max(left, int(float64(k)-float64(i)*s/float64(n)+sd)) - newRight := min(right, int(float64(k)+float64(n-i)*s/float64(n)+sd)) - floydRivest(array, newLeft, newRight, k, cmp) - } - t := array[k] - i := left - j := right - array[left], array[k] = array[k], array[left] - if cmp(array[right], t) > 0 { - array[right], array[left] = array[left], array[right] - } - for i < j { - array[i], array[j] = array[j], array[i] - i++ - j-- - for ; cmp(array[i], t) < 0; i++ { - } - for ; cmp(array[j], t) > 0; j-- { - } - } - if cmp(array[left], t) == 0 { - array[left], array[j] = array[j], array[left] - } else { - j++ - array[j], array[right] = array[right], array[j] - } - if j <= k { - left = j + 1 - } - if k <= j { - right = j - 1 - } - } -} - -func quickSelect[S ~[]E, E any](list S, k int, cmp func(E, E) int) { - floydRivest(list, 0, len(list)-1, k, cmp) -} - -func bucketRange(colors []color.RGBA) color.RGBA { - if len(colors) == 0 { - return color.RGBA{} - } - var minR, minG, minB uint8 = math.MaxUint8, math.MaxUint8, math.MaxUint8 - var maxR, maxG, maxB uint8 - for _, c := range colors { - minR, maxR = min(minR, c.R), max(maxR, c.R) - minG, maxG = min(minG, c.G), max(maxG, c.G) - minB, maxB = min(minB, c.B), max(maxB, c.B) - } - return color.RGBA{R: maxR - minR, G: maxG - minG, B: maxB - minB} -} - -func cutOnce(colors []color.RGBA, bucketRange color.RGBA) [2][]color.RGBA { - if len(colors) == 0 { - return [...][]color.RGBA{colors, colors} - } - rRange, gRange, bRange := bucketRange.R, bucketRange.G, bucketRange.B - if rRange >= gRange && rRange >= bRange { - quickSelect(colors, len(colors)/2, func(x, y color.RGBA) int { return int(x.R) - int(y.R) }) - } else if gRange >= rRange && gRange >= bRange { - quickSelect(colors, len(colors)/2, func(x, y color.RGBA) int { return int(x.G) - int(y.G) }) - } else { - quickSelect(colors, len(colors)/2, func(x, y color.RGBA) int { return int(x.B) - int(y.B) }) - } - return [...][]color.RGBA{colors[:len(colors)/2], colors[len(colors)/2:]} -} - -func colorAvg(colors []color.RGBA) color.RGBA { - var r, g, b int64 - for _, c := range colors { - r += int64(c.R) - g += int64(c.G) - b += int64(c.B) - } - n := int64(len(colors)) - return color.RGBA{R: uint8(divRound(r, n)), G: uint8(divRound(g, n)), B: uint8(divRound(b, n)), A: 0xff} -} - -func medianCut(img image.Image) color.Palette { - var colors []color.RGBA - for y := img.Bounds().Min.Y; y < img.Bounds().Max.Y; y++ { - for x := img.Bounds().Min.X; x < img.Bounds().Max.X; x++ { - r, g, b, a := img.At(x, y).RGBA() - if a > 0 { - colors = append(colors, color.RGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: 0xff}) - } - } - } - buckets := [][]color.RGBA{colors} - bucketRanges := []color.RGBA{{}} - for { - var bestRange uint8 - var bestIdx int - for i, rng := range bucketRanges { - r := max(rng.R, rng.G, rng.B) - if r >= bestRange { - bestRange = r - bestIdx = i - } - } - split := cutOnce(buckets[bestIdx], bucketRanges[bestIdx]) - buckets = slices.Replace(buckets, bestIdx, bestIdx+1, split[:]...) - if len(buckets) == 255 { - break - } - bucketRanges = slices.Replace(bucketRanges, bestIdx, bestIdx+1, bucketRange(split[0]), bucketRange(split[1])) - } - palette := color.Palette{color.Transparent} - for _, b := range buckets { - if len(b) > 0 { - palette = append(palette, colorAvg(b)) - } - } - return palette -} - -func printImg(img image.Image, maxX, maxY, pixelX, pixelY int) error { - x := min(img.Bounds().Dx(), maxX) - y := min(img.Bounds().Dy(), maxY) - - // Try not to stretch the image. - if y == 0 || x != 0 && img.Bounds().Dy()*x*pixelX <= img.Bounds().Dx()*y*pixelY { - y = divRound(img.Bounds().Dy()*x*pixelX, img.Bounds().Dx()*pixelY) - } else { - x = divRound(img.Bounds().Dx()*y*pixelY, pixelX*img.Bounds().Dy()) - } - - if x != img.Bounds().Dx() || y != img.Bounds().Dy() { - dst := image.NewRGBA(image.Rect(0, 0, x, y)) - draw.BiLinear.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Src, nil) - img = dst - } - - switch *m { - case modeBlock: - sixel.PrintXTerm16(os.Stdout, img) - case modeBlock24: - sixel.PrintBlock(os.Stdout, img) - case modeSixel: - sixel.Print(os.Stdout, img, medianCut(img)) - } - fmt.Println() - return nil -} - -func icat(args []string) error { - if len(args) == 0 { - return errors.New("missing positional argument") - } - if len(args) > 1 { - return errors.New("too many positional arguments") - } - file := args[0] - - cols := *x - lines := *y - pixelX := 2 - pixelY := 5 - if cols == 0 && lines == 0 { - ws, err := unix.IoctlGetWinsize(int(os.Stdout.Fd()), unix.TIOCGWINSZ) - if err != nil { - return err - } - cols = int(ws.Col) - lines = int(ws.Row) - lines-- // Leave a line for the status bar. - cellX, cellY := int(ws.Xpixel)/int(ws.Col), int(ws.Ypixel)/int(ws.Row) - if cellX == 0 && cellY == 0 { - cellX, cellY = 10, 20 - } - if *m == modeSixel { - cols *= cellX - lines *= cellY - } else { - pixelX, pixelY = cellX, cellY - } - } - switch *m { - case modeSixel: - pixelX, pixelY = 1, 1 - case modeBlock24: - lines *= 2 - pixelX *= 2 - } - - img, err := load(file) - if err != nil { - return err - } - - if err := printImg(img, cols, lines, pixelX, pixelY); err != nil { - return err - } - return nil -} - -func main() { - flag.Usage = func() { - fmt.Fprintf(os.Stderr, "Usage: icat FILE\n") - } - flag.Parse() - - if err := icat(flag.Args()); err != nil { - fmt.Fprintf(os.Stderr, "Failed: %s\n", err) - os.Exit(1) - } -} diff --git a/icat/bench_test.go b/icat/bench_test.go new file mode 100644 index 0000000..1e79a10 --- /dev/null +++ b/icat/bench_test.go @@ -0,0 +1,20 @@ +package main + +import ( + "cmp" + "math/rand/v2" + "testing" +) + +func BenchmarkQuickSelect(b *testing.B) { + rng := rand.New(rand.NewPCG(0, 0)) + myTestCase := make([]int, 3840*2160) + for b.Loop() { + b.StopTimer() + for i := range myTestCase { + myTestCase[i] = rng.Int() + } + b.StartTimer() + quickSelect(myTestCase, len(myTestCase)/2, cmp.Compare) + } +} diff --git a/icat/icat.go b/icat/icat.go new file mode 100644 index 0000000..2b69170 --- /dev/null +++ b/icat/icat.go @@ -0,0 +1,324 @@ +// The icat command displays an image to the terminal using block characters. +package main + +import ( + "errors" + "flag" + "fmt" + "image" + "image/color" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "math" + "os" + "slices" + + _ "golang.org/x/image/bmp" + "golang.org/x/image/draw" + _ "golang.org/x/image/tiff" + _ "golang.org/x/image/webp" + "golang.org/x/sys/unix" + "roseh.moe/pkg/sixel" +) + +var ( + x = flag.Int("x", 0, "set image width in columns") + y = flag.Int("y", 0, "set image height in rows") + m = flagPrintMode(flag.CommandLine, "m", modeBlock24, "one of 'block', 'block24', or 'sixel'") +) + +type printMode int + +const ( + modeInvalid printMode = iota + modeBlock + modeBlock24 + modeSixel +) + +type printModeValue printMode + +func (m *printModeValue) String() string { + switch printMode(*m) { + case modeBlock: + return "block" + case modeBlock24: + return "block24" + case modeSixel: + return "sixel" + default: + return "invalid" + } +} + +func (m *printModeValue) Set(s string) error { + var mode printMode + switch s { + case "block": + mode = modeBlock + case "block24": + mode = modeBlock24 + case "sixel": + mode = modeSixel + default: + return fmt.Errorf("bad mode type %q, should be one of 'block', 'block24', or 'sixel'", s) + } + *m = printModeValue(mode) + return nil +} + +func flagPrintMode(fs *flag.FlagSet, name string, value printMode, usage string) *printMode { + fs.Var((*printModeValue)(&value), name, usage) + return &value +} + +func divRound[N ~int64 | ~int](n, d N) N { + return (n + d/2) / d +} + +func load(filename string) (image.Image, error) { + file := os.Stdin + if filename != "-" { + var err error + file, err = os.Open(filename) + if err != nil { + return nil, err + } + defer file.Close() + } + + img, _, err := image.Decode(file) + if err != nil { + return nil, fmt.Errorf("decode %q: %s", filename, err) + } + + return img, nil +} + +func sign(n int) float64 { + if n < 0 { + return -1 + } + if n > 0 { + return 1 + } + return 0 +} + +func floydRivest[S ~[]E, E any](array S, left, right, k int, cmp func(E, E) int) { + for right > left { + if right-left > 600 { + n := right - left + 1 + i := k - left + 1 + z := math.Log(float64(n)) + s := .5 * math.Exp(2*z/3) + sd := .5 * math.Sqrt(z*s*(float64(n)-s)/float64(n)) * sign(i-n/2) + newLeft := max(left, int(float64(k)-float64(i)*s/float64(n)+sd)) + newRight := min(right, int(float64(k)+float64(n-i)*s/float64(n)+sd)) + floydRivest(array, newLeft, newRight, k, cmp) + } + t := array[k] + i := left + j := right + array[left], array[k] = array[k], array[left] + if cmp(array[right], t) > 0 { + array[right], array[left] = array[left], array[right] + } + for i < j { + array[i], array[j] = array[j], array[i] + i++ + j-- + for ; cmp(array[i], t) < 0; i++ { + } + for ; cmp(array[j], t) > 0; j-- { + } + } + if cmp(array[left], t) == 0 { + array[left], array[j] = array[j], array[left] + } else { + j++ + array[j], array[right] = array[right], array[j] + } + if j <= k { + left = j + 1 + } + if k <= j { + right = j - 1 + } + } +} + +func quickSelect[S ~[]E, E any](list S, k int, cmp func(E, E) int) { + floydRivest(list, 0, len(list)-1, k, cmp) +} + +func bucketRange(colors []color.RGBA) color.RGBA { + if len(colors) == 0 { + return color.RGBA{} + } + var minR, minG, minB uint8 = math.MaxUint8, math.MaxUint8, math.MaxUint8 + var maxR, maxG, maxB uint8 + for _, c := range colors { + minR, maxR = min(minR, c.R), max(maxR, c.R) + minG, maxG = min(minG, c.G), max(maxG, c.G) + minB, maxB = min(minB, c.B), max(maxB, c.B) + } + return color.RGBA{R: maxR - minR, G: maxG - minG, B: maxB - minB} +} + +func cutOnce(colors []color.RGBA, bucketRange color.RGBA) [2][]color.RGBA { + if len(colors) == 0 { + return [...][]color.RGBA{colors, colors} + } + rRange, gRange, bRange := bucketRange.R, bucketRange.G, bucketRange.B + if rRange >= gRange && rRange >= bRange { + quickSelect(colors, len(colors)/2, func(x, y color.RGBA) int { return int(x.R) - int(y.R) }) + } else if gRange >= rRange && gRange >= bRange { + quickSelect(colors, len(colors)/2, func(x, y color.RGBA) int { return int(x.G) - int(y.G) }) + } else { + quickSelect(colors, len(colors)/2, func(x, y color.RGBA) int { return int(x.B) - int(y.B) }) + } + return [...][]color.RGBA{colors[:len(colors)/2], colors[len(colors)/2:]} +} + +func colorAvg(colors []color.RGBA) color.RGBA { + var r, g, b int64 + for _, c := range colors { + r += int64(c.R) + g += int64(c.G) + b += int64(c.B) + } + n := int64(len(colors)) + return color.RGBA{R: uint8(divRound(r, n)), G: uint8(divRound(g, n)), B: uint8(divRound(b, n)), A: 0xff} +} + +func medianCut(img image.Image) color.Palette { + var colors []color.RGBA + for y := img.Bounds().Min.Y; y < img.Bounds().Max.Y; y++ { + for x := img.Bounds().Min.X; x < img.Bounds().Max.X; x++ { + r, g, b, a := img.At(x, y).RGBA() + if a > 0 { + colors = append(colors, color.RGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: 0xff}) + } + } + } + buckets := [][]color.RGBA{colors} + bucketRanges := []color.RGBA{{}} + for { + var bestRange uint8 + var bestIdx int + for i, rng := range bucketRanges { + r := max(rng.R, rng.G, rng.B) + if r >= bestRange { + bestRange = r + bestIdx = i + } + } + split := cutOnce(buckets[bestIdx], bucketRanges[bestIdx]) + buckets = slices.Replace(buckets, bestIdx, bestIdx+1, split[:]...) + if len(buckets) == 255 { + break + } + bucketRanges = slices.Replace(bucketRanges, bestIdx, bestIdx+1, bucketRange(split[0]), bucketRange(split[1])) + } + palette := color.Palette{color.Transparent} + for _, b := range buckets { + if len(b) > 0 { + palette = append(palette, colorAvg(b)) + } + } + return palette +} + +func printImg(img image.Image, maxX, maxY, pixelX, pixelY int) error { + x := min(img.Bounds().Dx(), maxX) + y := min(img.Bounds().Dy(), maxY) + + // Try not to stretch the image. + if y == 0 || x != 0 && img.Bounds().Dy()*x*pixelX <= img.Bounds().Dx()*y*pixelY { + y = divRound(img.Bounds().Dy()*x*pixelX, img.Bounds().Dx()*pixelY) + } else { + x = divRound(img.Bounds().Dx()*y*pixelY, pixelX*img.Bounds().Dy()) + } + + if x != img.Bounds().Dx() || y != img.Bounds().Dy() { + dst := image.NewRGBA(image.Rect(0, 0, x, y)) + draw.BiLinear.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Src, nil) + img = dst + } + + switch *m { + case modeBlock: + sixel.PrintXTerm16(os.Stdout, img) + case modeBlock24: + sixel.PrintBlock(os.Stdout, img) + case modeSixel: + sixel.Print(os.Stdout, img, medianCut(img)) + } + fmt.Println() + return nil +} + +func icat(args []string) error { + if len(args) == 0 { + return errors.New("missing positional argument") + } + if len(args) > 1 { + return errors.New("too many positional arguments") + } + file := args[0] + + cols := *x + lines := *y + pixelX := 2 + pixelY := 5 + if cols == 0 && lines == 0 { + ws, err := unix.IoctlGetWinsize(int(os.Stdout.Fd()), unix.TIOCGWINSZ) + if err != nil { + return err + } + cols = int(ws.Col) + lines = int(ws.Row) + lines-- // Leave a line for the status bar. + cellX, cellY := int(ws.Xpixel)/int(ws.Col), int(ws.Ypixel)/int(ws.Row) + if cellX == 0 && cellY == 0 { + cellX, cellY = 10, 20 + } + if *m == modeSixel { + cols *= cellX + lines *= cellY + } else { + pixelX, pixelY = cellX, cellY + } + } + switch *m { + case modeSixel: + pixelX, pixelY = 1, 1 + case modeBlock24: + lines *= 2 + pixelX *= 2 + } + + img, err := load(file) + if err != nil { + return err + } + + if err := printImg(img, cols, lines, pixelX, pixelY); err != nil { + return err + } + return nil +} + +func main() { + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage: icat FILE\n") + } + flag.Parse() + + if err := icat(flag.Args()); err != nil { + fmt.Fprintf(os.Stderr, "Failed: %s\n", err) + os.Exit(1) + } +} -- cgit v1.3.1