Skip to content

Added ASCII automation script, cleaned up code, and updated README #1

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,20 @@
assets/
# Binaries and build outputs
ascii_converter

# Output directory for generated ASCII art
ascii_output/

# Input directory for images (optional to ignore)
input_images/

# Dependency management files (if not needed to share)
*.mod
*.sum

# System-specific files
.DS_Store
Thumbs.db

# Temporary files and logs
*.log
*.tmp
88 changes: 73 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,79 @@
# ascii_gen
A script to generate basic colorful ASCII art from Images.

## Demo
![Demo](https://github.com/pulkitsharma07/pulkitsharma07.github.io/raw/development/assets/render1546308037772.gif)
# ASCII Art Generator

## Working
The code has ample comments documenting it.
### Description
A Go-based script that generates **colorful ASCII art** from images. It processes each 8x8 window of pixels and maps them to the most visually similar character, creating stunning and artistic output.

Basically, it maps each 8x8 window of pixels of the image to the character which is most similar to
that window.
---

The code has enough comments to get you going, I recommend starting out with `chars/chars.go` and then
move to the `main()` function in `main.go`.
### Demo
Enjoy a demo of the ASCII art generator:
![Demo Placeholder](demo.gif)

## Running/Installing
* clone this repo
* `go get github.com/nfnt/resize` (Required for resizing the image to suitable dimensions)
* `go run main.go /path/to/image.jpeg`
---

*PS: I am developing this mainly to learn Go, I do not have any considerable experience in writing Go (or ASCII generators for that matter). Please feel free to point out issues in the code, style, or anything in general !*
### Features
- Converts images to ASCII art with color preservation.
- Efficiently processes images using 8x8 pixel windows for accurate character mapping.
- Utilizes parallel processing for faster conversion, even for larger images.
- Fully automated workflow with an included Bash script.
- Easily customizable to fit your artistic or practical needs.

---

### Setup Instructions
#### **Clone and Initialize the Project**
1. Clone this repository:
```bash
git clone <your_repo_url>
cd ascii_gen
```

2. Initialize the Go module (if not already done):
```bash
go mod init ascii_gen
```

3. Install the required package for image resizing:
```bash
go get github.com/nfnt/resize
```

#### **Run the Script**
1. Add your images to the `input_images` directory.
2. Run the automated Bash script:
```bash
./ascii_converter.sh
```
The ASCII art output will be saved to the `ascii_output` directory.

#### **Manual Execution**
Alternatively, you can run the Go program manually:
```bash
go run main.go /path/to/image.jpeg
```
Replace `/path/to/image.jpeg` with the actual path to your image file.

---

### Folder Structure
```
ascii_gen/
├── go.mod # Go module setup
├── go.sum # Tracks project dependencies
├── main.go # Main script to generate ASCII art
├── chars/ # Contains logic for mapping patterns to characters
│ ├── chars.go
├── input_images/ # Place images here for processing
├── ascii_output/ # ASCII art output directory
└── ascii_converter.sh # Bash script for automation
```

---

### Development Notes
- This project was developed primarily as a learning exercise in Go.
- Feel free to explore the `chars/chars.go` file to understand the character mapping logic, followed by the `main.go` file for the overall process.
- Contributions and suggestions are welcome to improve the code, style, or functionality.

---
49 changes: 49 additions & 0 deletions ascii_converter.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/bin/bash

# Ensure the script exits on the first error
set -e

# Project setup
echo "[INFO] Initializing project and dependencies..."
if [ ! -f "go.mod" ]; then
go mod init github.com/osama/ascii_gen
fi

# Tidy dependencies
go mod tidy

# Install required Go package
echo "[INFO] Installing dependencies..."
go get github.com/nfnt/resize

# Compile Go program
echo "[INFO] Building the ASCII converter..."
go build -o ascii_converter main.go

# Create output directory if it doesn't exist
OUTPUT_DIR="ascii_output"
mkdir -p "$OUTPUT_DIR"

# Convert all images in the 'input_images' directory
INPUT_DIR="input_images"
echo "[INFO] Processing images from directory: $INPUT_DIR"

if [ ! -d "$INPUT_DIR" ]; then
echo "[INFO] Creating 'input_images' directory. Please add your images here."
mkdir -p "$INPUT_DIR"
exit 0
fi

if ls "$INPUT_DIR"/* &>/dev/null; then
for image in "$INPUT_DIR"/*; do
filename=$(basename -- "$image")
output_file="$OUTPUT_DIR/${filename%.*}.txt"
echo "[INFO] Converting $filename to ASCII..."
./ascii_converter "$image" > "$output_file"
echo "[INFO] ASCII art saved to $output_file"
done
else
echo "[INFO] No images found in $INPUT_DIR. Add images and rerun the script."
fi

echo "[INFO] Done! ASCII art can be found in the $OUTPUT_DIR directory."
Binary file added demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
381 changes: 134 additions & 247 deletions main.go
Original file line number Diff line number Diff line change
@@ -1,298 +1,185 @@
package main

import (
chars "./chars"
"fmt"
"github.com/nfnt/resize"
"image"
jpeg "image/jpeg"
"math/bits"
"os"
"fmt"
"github.com/nfnt/resize"
"image"
"image/jpeg"
"math/bits"
"os"

"github.com/username/ascii_gen/chars" // Fixed the relative import path
)

// Defining a custom class here for Color to find the average color,
// converting to 8-bit space, and so on
// Defining a custom class for Color operations
type color struct {
r, g, b uint32
r, g, b uint32
}

func (a *color) Add(v *color) *color {
a.r += v.r
a.g += v.g
a.b += v.b
a.r += v.r
a.g += v.g
a.b += v.b

return a
return a
}

// Converts R,G,B values back to 8 bits.
func (a *color) retrofy() *color {
a.Div(0x101)
return a
a.Div(0x101)
return a
}

func (a *color) Div(number uint32) *color {
a.r /= number
a.g /= number
a.b /= number
a.r /= number
a.g /= number
a.b /= number

return a
return a
}

func (a *color) RGB() (uint32, uint32, uint32) {
return a.r, a.g, a.b
return a.r, a.g, a.b
}

// Refer: https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit
func getCharWithColor(bestChar string, c *color) string {
c.retrofy()
return fmt.Sprintf("\x1b[38;2;%d;%d;%dm%s\x1b[0m", c.r, c.g, c.b, bestChar)
c.retrofy()
return fmt.Sprintf("\x1b[38;2;%d;%d;%dm%s\x1b[0m", c.r, c.g, c.b, bestChar)
}

// Here <pattern> represents a 8x8 window of the image compressed into a 64 bit number
// and c represents the dominant color in that window, i.e. the color of the ASCII char
func getClosestChar(pattern uint64, c *color) string {
maxDistance := 100
var bestLetter string

// Go through each character Mapping we have in chars.CharMap
for k, v := range chars.CharMap {

// Count the number of bits which are different in the pattern and the character
// This count represents dissimilar these 2 8x8 images are.
// Remember both are actually 8x8 images/patterns packed in 64 bit numbers.
// Here we take the XOR of these two numbers, which gives us count of bits which are different.
distance := bits.OnesCount64(v ^ pattern)

// We need to store the character which is the most similar.
// i.e. having the least number of different bits between it and the pattern.
if distance < maxDistance {
bestLetter = k
maxDistance = distance
}
}

return getCharWithColor(bestLetter, c)
maxDistance := 100
var bestLetter string

for k, v := range chars.CharMap {
distance := bits.OnesCount64(v ^ pattern)
if distance < maxDistance {
bestLetter = k
maxDistance = distance
}
}

return getCharWithColor(bestLetter, c)
}

// TODO: Move this inside windowProcessor
func getPackedFormOfWindow(img image.Image, winX, winY, w, h int, threshold uint32) uint64 {
// <pattern> will eventually be the packed form of the current 8 x 8 window
// The packing will be similar to the one done in chars.CharMap
// Refer the comment there for more details.
var pattern uint64 = 0

// Start assigning values in <pattern> from the MSB.
// <cnt> indicates the bit to currently set/unset
cnt := 63

for y := winY; y < winY+8 && y < h; y++ {
for x := winX; x < winX+8 && x < w; x++ {
r, g, b, _ := img.At(x, y).RGBA()

// We need to somewhow represent this RGB value as 0/1.
// This is known as 'binarization', There can be multiple ways to do this.
// Overall, it depends on some value (<threshold> here), which governs whether this pixel/bit
// will be a 0 or a 1.
if r+g+b >= threshold {
pattern |= 1 << uint(cnt) // Set the <cnt>th bit in pattern as this pixel is above the threshold.
}

cnt-- // Move towards LSB
}
}

return pattern
var pattern uint64 = 0
cnt := 63

for y := winY; y < winY+8 && y < h; y++ {
for x := winX; x < winX+8 && x < w; x++ {
r, g, b, _ := img.At(x, y).RGBA()
if r+g+b >= threshold {
pattern |= 1 << uint(cnt)
}
cnt--
}
}

return pattern
}

// TODO: Move this inside windowProcessor
func getMeanColorForWindow(img image.Image, winX, winY, w, h int) *color {
colorAccum := &color{0, 0, 0}

// Just go through all the pixels in the current window
// While ensuring that we don't cross the image bounds.
//
// Again, the order of scanning is important as we we want to store
// the top most line on the most significant 8 bits.
for y := winY; y < winY+8 && y < h; y++ {
for x := winX; x < winX+8 && x < w; x++ {
// Read the R,G,B values of the image at pixel <x>,<y>
r, g, b, _ := img.At(x, y).RGBA()
colorAccum.Add(&color{r, g, b})
}
}

return colorAccum.Div(64)
colorAccum := &color{0, 0, 0}
for y := winY; y < winY+8 && y < h; y++ {
for x := winX; x < winX+8 && x < w; x++ {
r, g, b, _ := img.At(x, y).RGBA()
colorAccum.Add(&color{r, g, b})
}
}

return colorAccum.Div(64)
}

// windowProcessor is the object which holds required information for
// binarization and coloring.
type windowProcessor struct {
img image.Image
winX, winY, w, h, buffI, buffJ int
img image.Image
winX, winY, w, h, buffI, buffJ int
}

// Results to be pushed on the channel.
// The go routine cannot directly write to the buffer because of race issues.
type windowProcessorResult struct {
c string
buffI, buffJ int
c string
buffI, buffJ int
}

// Runs runs a pipeline of different operations on the image (window of size 8x8 currently)
// Once the operations complete, it determines the ASCII character which should represent
// this particular window and pushes that on the <inform> channel.
func (p windowProcessor) Run(inform chan windowProcessorResult) {
//
// First, we figure out the color is dominant in this 8x8 window.
// Therefore, we can draw the character with that color in order to convey the color information.
// These can be done in multiple ways: (mean/mode/median/maximum) value of R,G,Bs in the window
//
// Here, we are going to try out the mean color.
avgColor := getMeanColorForWindow(p.img, p.winX, p.winY, p.w, p.h)

// Not really 'intensity' in the proper sense. But some kind of value to indicate the "brightness"
avgIntensity := uint32(avgColor.r + avgColor.g + avgColor.b/3)

// Pack the current window into a 64 bit integer by performing binarization.
// Details in the function definition.
packedWindow := getPackedFormOfWindow(p.img, p.winX, p.winY, p.w, p.h, avgIntensity)

// Figure out and print the character whose 8x8 representation is most similar to the current 8x8 window
char := getClosestChar(packedWindow, avgColor)

inform <- windowProcessorResult{char, p.buffI, p.buffJ}
avgColor := getMeanColorForWindow(p.img, p.winX, p.winY, p.w, p.h)
avgIntensity := uint32(avgColor.r + avgColor.g + avgColor.b/3)
packedWindow := getPackedFormOfWindow(p.img, p.winX, p.winY, p.w, p.h, avgIntensity)
char := getClosestChar(packedWindow, avgColor)
inform <- windowProcessorResult{char, p.buffI, p.buffJ}
}

func displayBuffer(buffer [][]string) {
for _, v := range buffer {
for _, s := range v {
fmt.Printf("%s", s)
}
fmt.Printf("\n")
}
for _, v := range buffer {
for _, s := range v {
fmt.Printf("%s", s)
}
fmt.Printf("\n")
}
}

func printImage(path string, ascii_width uint) {
// Open the image present at <path>
f, err := os.Open(path)
if err != nil {
fmt.Printf("Some Error occured while opening %s: Erro: %v", path, err)
return
}

// Try to read as JPEG
img_big, err := jpeg.Decode(f)
bounds := img_big.Bounds()
aspect_ratio := float64(bounds.Max.X) / float64(bounds.Max.Y)

// Resize according to width
// The scripts converts each 8 x 8 block of image to 1 character.
// Therefore, in order to write X characters per line, the image should be resized to 8*X.
// Which maybe bigger/smaller than the original image.
width := ascii_width * 8

// There interesting bit here is that, we are not preserving the aspect ratio of the image while
// resizing. Specifically, we make the height about half the value it is supposed to be wrt to the width.
// This is done because, if we don't rescale the image, it will show up as squished in ASCII.
//
// The generated ASCII image has somewhat similar aspect ratio (visually) to that of the source image.
height := uint(float64(width) * 0.45 / aspect_ratio)

img := resize.Resize(width, height, img_big, resize.Lanczos3)
bounds = img.Bounds()
w, h := bounds.Max.X, bounds.Max.Y

// Create a 2D buffer of ASCII chars.
// This is required the goroutines responsible for processing window can/will finish in a random
// sequence.
// Therefore, we can't just draw the chars on the screen at the end of each goroutine's execution.
// We need a way to set characters in arbitrary location on the final image.
// So, we use a buffer here.
// Instead of drawing on the screen directly, the go routines will set the appropriate characters
// in this buffer, and once all go routines are done processing the image, we can finally draw the
// buffer on the screen in a single go.
buffer := make([][]string, h/8+1)
for i := range buffer {
buffer[i] = make([]string, w/8+1)
}

// Each go routine must know what coordinate in the buffer is it respondible for.
// These variables are used to track that.
buffI, buffJ := 0, 0

// Common pattern to invoke multiple workers is to create 2 channels, one on which work is published,
// and another one where the main go routine waits for all of the work which was generated to be completed.
inform := make(chan windowProcessorResult)
done := make(chan bool)
numProcessors := 0

// We need to scan (and draw) the image from left to right (and top to bottom)
// Here winX, winY represents the top-left corner of the 8x8 window of the image, which will be
// mapped to a single character.
//
// We move the window by 8 units, since we don't want to read the same pixels again.
// Also, we move alone X axis first and then Y axis, because of the reasons stated earlier.
for winY := 0; winY < h; winY += 8 {
buffJ = 0
for winX := 0; winX < w; winX += 8 {
// Create a processor responsible for processing this 8x8 window
processor := windowProcessor{img, winX, winY, w, h, buffI, buffJ}

// Run the processor as a goroutine \m/
//
// <inform> is a channel where the processor will inform back with the appropriate ASCII
// representation for this window.
go processor.Run(inform)

// Store count of processors for closing the channel
numProcessors++
buffJ++
}
buffI++
}

// Start a go routine which collects the results from all of the processor go routines
// and writes to the buffer.
// This ensures that there are no race-conditions while accessing the buffer.
//
// Refer:
// https://gobyexample.com/closing-channels
go func() {
resultsReceived := 0
for {
result, more := <-inform
if more {
resultsReceived++
buffer[result.buffI][result.buffJ] = result.c

// Close channel once all information is received.
// On the next call to `<- inform`, we will exit the loop, as <more> will not be true.
if resultsReceived == numProcessors {
close(inform)
}
} else {
break
}
}

// Display the buffer contents on the screen
displayBuffer(buffer)

// Push to done channel
done <- true
}()

<-done
f, err := os.Open(path)
if err != nil {
fmt.Printf("Error opening %s: %v", path, err)
return
}

img_big, err := jpeg.Decode(f)
bounds := img_big.Bounds()
aspect_ratio := float64(bounds.Max.X) / float64(bounds.Max.Y)
width := ascii_width * 8
height := uint(float64(width) * 0.45 / aspect_ratio)
img := resize.Resize(width, height, img_big, resize.Lanczos3)

bounds = img.Bounds()
w, h := bounds.Max.X, bounds.Max.Y
buffer := make([][]string, h/8+1)
for i := range buffer {
buffer[i] = make([]string, w/8+1)
}

buffI, buffJ := 0, 0
inform := make(chan windowProcessorResult)
done := make(chan bool)
numProcessors := 0

for winY := 0; winY < h; winY += 8 {
buffJ = 0
for winX := 0; winX < w; winX += 8 {
processor := windowProcessor{img, winX, winY, w, h, buffI, buffJ}
go processor.Run(inform)
numProcessors++
buffJ++
}
buffI++
}

go func() {
resultsReceived := 0
for {
result, more := <-inform
if more {
resultsReceived++
buffer[result.buffI][result.buffJ] = result.c
if resultsReceived == numProcessors {
close(inform)
}
} else {
break
}
}

displayBuffer(buffer)
done <- true
}()

<-done
}

func main() {
// Number of characters per line
var width uint = 150

imgs := os.Args[1:]

for _, img_path := range imgs {
// Display Image
printImage(img_path, width)
}
var width uint = 150
imgs := os.Args[1:]
for _, img_path := range imgs {
printImage(img_path, width)
}
}