Skip to content

Commit

Permalink
Optimized, more precise KD Search, embedded binary precomputations an…
Browse files Browse the repository at this point in the history
…d palettes, more color space, palette marshalling and unmarshalling.
  • Loading branch information
wbrown committed Aug 7, 2024
1 parent 020dcc5 commit 7ac0692
Show file tree
Hide file tree
Showing 28 changed files with 1,804 additions and 544 deletions.
40 changes: 26 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ and text-based display.
6. **Optimized for Text Output**: Designed to produce ANSI escape code
sequences, making it ideal for terminal-based image display.

7. **Optimized KD Tree Search**: Optimized for ANSI art generation by
precomputing quantized color distances.

## How It Works

The algorithm processes the input image in 2x2 blocks, determining the best
Expand Down Expand Up @@ -60,13 +63,14 @@ uses the 256 color scheme.
To build the program, run the following commands:

```sh
go get -u github.com/wbrown/ansi2img
go build github.com/wbrown/ansi2img/cmd/ansify
```

## Usage
`./img2ansi -input <input> [-output <output>] [-width <width>]
[-scale <scale>] [-quantization <quantization>] [-maxchars <maxchars>]
[-8bit] [-jb] [-table]`
[-color_method <color_method>] [-palette <palette>] [-kdsearch <kdsearch>]
[-cache_threshold <cache_threshold>]`

**Performance**

Expand All @@ -90,19 +94,29 @@ image. The cache is used to speed up the program by not having to recompute
the blocks for each 2x2 pixel block in the image. It is a fuzzy cache, so it
is thresholded on error distance from the target block.

There are built in embedded palettes that have precomputed tables for the
colors. These are `ansi16`, `ansi256`, and `jetbrains32`. Each precomputed
palette also has three color spaces that are precomputed: `RGB`, `Lab`, and
`Redmean`. The default is `Redmean`.

**Colors**

By default the program uses the 16-color ANSI palette, split into 8 foreground
colors and 8 background colors. The `-8bit` option can be used to enable 256
color mode. The `-jb` option can be used to use the JetBrains color scheme,
which allows for separate foreground and background palettes to effectively
double the number of colors available.

The program performes well without quantization, but if you want to reduce the
colors and 8 background colors. There are three palettes built in, selectable
by using the `-palette` option:
* `ansi16`: The default 16-color ANSI palette
* `ansi256`: The 256-color ANSI palette
* `jetbrains32`: The JetBrains color scheme that uses 32 colors by having
separate palettes for foreground and background colors.
The program performs well without quantization, but if you want to reduce the
number of colors in the output, you can use the `-quantization` option. The
default is `256` colors. This isn't the output colors, but the number of
colors used in the quantization step.

There are three color space options available: `RGB`, `Lab`, and `Redmean`.
The most perceptually accurate is `Lab`, but it is also the slowest. The
default is `Redmean`.

**Image Size**

The `-width` option can be used to set the target width of the output image,
Expand All @@ -111,26 +125,24 @@ default `-scale` is `2`, which approximately halves the height of the output,
to compensate for the fact that characters are taller than they are wide.

```
-8bit
Use 8-bit ANSI colors (256 colors)
-cache_threshold float
Threshold for block cache (default 40)
-colormethod string
Color distance method: RGB, LAB, or Redmean (default "RGB")
-input string
Path to the input image file (required)
-jb
Use JetBrains color scheme
-kdsearch int
Number of nearest neighbors to search in KD-tree, 0 to disable (default 50)
-maxchars int
Maximum number of characters in the output (default 1048576)
-output string
Path to save the output (if not specified, prints to stdout)
-palette string
Path to the palette file (Embedded: ansi16, ansi256, jetbrains32) (default "ansi16")
-quantization int
Quantization factor (default 256)
-scale float
Scale factor for the output image (default 2)
-table
Print ANSI color table
-width int
Target width of the output image (default 80)
```
27 changes: 24 additions & 3 deletions ansi.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
package main
package img2ansi

import (
"fmt"
"strings"
)

// compressANSI compresses an ANSI image by combining adjacent blocks with
type AnsiEntry struct {
Key uint32
Value string
}
type AnsiData []AnsiEntry

var (
fgAnsi = NewOrderedMap()
bgAnsi = NewOrderedMap()
)

// CompressANSI compresses an ANSI image by combining adjacent blocks with
// the same foreground and background colors. The function takes an ANSI
// image as a string and returns the more efficient ANSI image as a string.
func compressANSI(ansiImage string) string {
func CompressANSI(ansiImage string) string {
var compressed strings.Builder
var currentFg, currentBg, currentBlock string
var count int
Expand Down Expand Up @@ -124,6 +135,16 @@ func colorIsBackground(color string) bool {
color == "48"
}

// ToOrderedMap converts an AnsiData slice to an OrderedMap with the values
// as keys and the keys as values.
func (ansiData AnsiData) ToOrderedMap() *OrderedMap {
om := NewOrderedMap()
for _, entry := range ansiData {
om.Set(entry.Key, entry.Value)
}
return om
}

// renderToAnsi renders a 2D array of BlockRune structs to an ANSI string.
// It does not perform any compression or optimization.
func renderToAnsi(blocks [][]BlockRune) string {
Expand Down
30 changes: 15 additions & 15 deletions approximatecache.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package main
package img2ansi

import (
"math"
Expand All @@ -8,22 +8,22 @@ import (
// that is used to store approximate matches for a given
// block of 4 RGB values. Approximate matches are performed
// by comparing the error of a given match to a threshold
// value.
// Value.
//
// The key of the map is a Uint256, which is a 256-bit
// The Key of the map is a Uint256, which is a 256-bit
// unsigned integer that is used to represent the foreground
// and background colors of a block of 4 RGB values.
//
// There may be multiple matches for a given key, so the
// value of the map is a lookupEntry, which is a struct
// There may be multiple matches for a given Key, so the
// Value of the map is a lookupEntry, which is a struct
// that contains a slice of Match structs.
type ApproximateCache map[Uint256]lookupEntry

// Match is a struct that contains the rune, foreground
// color, background color, and error of a match. The error
// is a float64 value that represents the difference between
// is a float64 Value that represents the difference between
// the actual block of 4 RGB values and the pair of foreground
// and background colors encoded in the key as an Uint256.
// and background colors encoded in the Key as an Uint256.
type Match struct {
Rune rune
FG RGB
Expand All @@ -36,7 +36,7 @@ type lookupEntry struct {
}

// AddEntry adds a new entry to the cache. The entry is
// represented by a key, which is a Uint256, and a Match
// represented by a Key, which is a Uint256, and a Match
// struct that contains the rune, foreground color, background
// color, and error of the match.
func (cache ApproximateCache) addEntry(
Expand Down Expand Up @@ -70,19 +70,19 @@ func (cache ApproximateCache) addEntry(
}

// GetEntry retrieves an entry from the cache. The entry is
// represented by a key, which is a Uint256, and a block of
// represented by a Key, which is a Uint256, and a block of
// 4 RGB values. The function returns the rune, foreground
// color, background color, and a boolean value indicating
// color, background color, and a boolean Value indicating
// whether the entry was found in the cache.
//
// There may be multiple matches for a given key, so the
// function returns the match with the lowest error value.
// There may be multiple matches for a given Key, so the
// function returns the match with the lowest error Value.
func (cache ApproximateCache) getEntry(
k Uint256,
block [4]RGB,
isEdge bool,
) (rune, RGB, RGB, bool) {
baseThreshold := cacheThreshold
baseThreshold := CacheThreshold
if isEdge {
baseThreshold *= 0.7
}
Expand All @@ -101,10 +101,10 @@ func (cache ApproximateCache) getEntry(
}
}
if bestMatch != nil {
lookupHits++
LookupHits++
return bestMatch.Rune, bestMatch.FG, bestMatch.BG, true
}
}
lookupMisses++
LookupMisses++
return 0, RGB{}, RGB{}, false
}
2 changes: 1 addition & 1 deletion attic.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package main
package img2ansi

// Below are measured from the terminal using a color picker
//ansiOverrides = map[uint32]string{
Expand Down
150 changes: 150 additions & 0 deletions cmd/ansify/ansify.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package main

import (
"flag"
"fmt"
"github.com/wbrown/img2ansi"
"os"
"strings"
"time"
)

// printAnsiTable prints a table of ANSI colors and their corresponding
// codes for both foreground and background colors. The table is printed
// to stdout.
//func printAnsiTable(fgAnsi, bgAnsi *[]img2ansi.RGB) {
// // Header
// fgColors := make([]uint32, 0, len()
// fgAnsi.Iterate(func(key, value interface{}) {
// fgColors = append(fgColors, key.(uint32))
// })
// bgColors := make([]uint32, 0, bgAnsi.Len())
// bgAnsi.Iterate(func(key, value interface{}) {
// bgColors = append(bgColors, key.(uint32))
// })
// fmt.Printf("%17s", " ")
// for _, fg := range fgColors {
// fgAns, _ := fgAnsi.Get(fg)
// fmt.Printf(" %6x (%3s) ", fg, fgAns)
// }
// fmt.Println()
// for _, bg := range bgColors {
// bgAns, _ := bgAnsi.Get(bg)
// fmt.Printf(" %6x (%3s) ", bg, bgAns)
//
// for _, fg := range fgColors {
// fgAns, _ := fgAnsi.Get(fg)
// bgAns, _ := bgAnsi.Get(bg)
// fmt.Printf(" %s[%s;%sm %3s %3s %s[0m ",
// ESC, fgAns, bgAns, fgAns, bgAns, ESC)
// }
// fmt.Println()
// }
//}

func main() {
inputFile := flag.String("input", "",
"Path to the input image file (required)")
outputFile := flag.String("output", "",
"Path to save the output (if not specified, prints to stdout)")
paletteFile := flag.String("palette", "ansi16",
"Path to the palette file "+
"(Embedded: ansi16, ansi256, jetbrains32)")
targetWidth := flag.Int("width", 80,
"Target width of the output image")
scaleFactor := flag.Float64("scale", 2.0,
"Scale factor for the output image")
maxChars := flag.Int("maxchars", 1048576,
"Maximum number of characters in the output")
quantization := flag.Int("quantization", 256,
"Quantization factor")
kdSearchDepth := flag.Int("kdsearch", 50,
"Number of nearest neighbors to search in KD-tree, 0 to disable")
threshold := flag.Float64("cache_threshold", 40.0,
"Threshold for block cache")
colorMethod := flag.String("colormethod",
"RGB", "Color distance method: RGB, LAB, or Redmean")
//printTable := flag.Bool("table", false,
// "Print ANSI color table")
// Parse flags
flag.Parse()

// Validate required flags
if *inputFile == "" {
fmt.Println("Please provide the image using the -input flag")
flag.PrintDefaults()
return
}

//if *printTable {
// printAnsiTable()
// return
//}

// Update global variables
img2ansi.TargetWidth = *targetWidth
img2ansi.MaxChars = *maxChars
img2ansi.Quantization = *quantization
img2ansi.ScaleFactor = *scaleFactor
img2ansi.KdSearch = *kdSearchDepth
img2ansi.CacheThreshold = *threshold

*colorMethod = strings.ToLower(*colorMethod)
switch *colorMethod {
case "rgb":
img2ansi.CurrentColorDistanceMethod = img2ansi.MethodRGB
case "lab":
img2ansi.CurrentColorDistanceMethod = img2ansi.MethodLAB
case "redmean":
img2ansi.CurrentColorDistanceMethod = img2ansi.MethodRedmean
default:
fmt.Println("Invalid color distance method, options are RGB," +
" LAB, or Redmean")
os.Exit(1)
}

fg, bg, err := img2ansi.LoadPalette(*paletteFile)
if err != nil {
fmt.Printf("Error loading palette: %v\n", err)
os.Exit(1)
}
endInit := time.Now()
fmt.Printf(
"fg, bg, distinct colors: %d, %d, %d\n"+
"colormethod: %s\n"+
"distance table entries precomputed: %d\n",
len(*fg.ColorArr), len(*bg.ColorArr), img2ansi.DistinctColors,
*colorMethod, len(*fg.ClosestColorArr)+len(*bg.ClosestColorArr))
fmt.Printf("Initialization time: %v\n",
endInit.Sub(img2ansi.BeginInitTime))

if len(os.Args) < 2 {
fmt.Println("Please provide the path to the image as an argument")
return
}

// Generate ANSI art
ansiArt := img2ansi.ImageToANSI(*inputFile)
compressedArt := img2ansi.CompressANSI(ansiArt)
//compressedArt := ansiArt
endComputation := time.Now()

// Output result
if *outputFile != "" {
err := os.WriteFile(*outputFile, []byte(compressedArt), 0644)
if err != nil {
fmt.Printf("Error writing to file: %v\n", err)
return
}
fmt.Printf("Output written to %s\n", *outputFile)
} else {
fmt.Print(compressedArt)
}

fmt.Printf("Computation time: %v\n", endComputation.Sub(endInit))
fmt.Printf("BestBlock calculation time: %v\n", img2ansi.BestBlockTime)
fmt.Printf("Total string length: %d\n", len(ansiArt))
fmt.Printf("Compressed string length: %d\n", len(compressedArt))
fmt.Printf("Block Cache: %d hits, %d misses\n",
img2ansi.LookupHits, img2ansi.LookupMisses)
}
3 changes: 3 additions & 0 deletions cmd/ansify/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/wbrown/img2ansi/cmd/ansify

go 1.22.5
6 changes: 6 additions & 0 deletions cmd/ansify/go.work
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
go 1.22.5

use (
.
../..
)
Loading

0 comments on commit 7ac0692

Please sign in to comment.