Ascii Halftone Image

by mcgurkryan in Craft > Art

1021 Views, 15 Favorites, 0 Comments

Ascii Halftone Image

tesla.png

Turn an image into halftone using ascii

Supplies

The only tools you need are a text editor to write JavaScript html and a web browser to run it

Halftone Images

halftone.png
tesla2.png

Halftone images date back to early newspapers. Where even though only a single colour ink was used (typically black), images could be made to look grayscale by varying the size and/or density of ink circles. For example the image of Tesla above, although it appears as grayscale, is in fact a halftone image. When zoomed in you will see that the image is comprised entirely of either fully black or fully white pixels.

How to Achieve the Halftone Effect

This method yields good results for me using JavaScript. The code will be discussed later, here we just outline the primary steps.

  1. Load an image and convert to grayscale.
  2. Divide the image into 3 by 3 blocks. i.e. The first block are the 9 pixels at the top-left of the image, the second block are the 9 pixels immediately to the right.. etc
  3. Find the average value for each group of 9 pixels.
  4. Quantize the colour. If you think about our 3 by 3 grid of nine pixels, then there are only 10 possible shades of grey. Fully white when all pixels are white, then by colouring in one pixel at the time we achieve progressively darker shades of grey, until all 9 pixels are coloured achieving our 10th colour - fully black. Quantizing the image means scaling from 256 shades of grey to just 10.
  5. Create the new image, 9 pixels at a time, using our 10 colours from above, i.e. our 10 possible ink circles, or more correctly in our case - pixel density.

Using Ascii " .:-=+*#%@"

Interestingly, different ascii characters also have a pixel density. Think of a space " " having a pixel density of 0, verses the "@" character, which has a high pixel density. The ascii sequence above can be found online and is suggested as being suitable to use when creating halftone images with 10 shades of grey. Just as in our procedure above - after Quantizing our image blocks, instead of creating a new image using pixel density, we can simply use the corresponding character from the sequence above, which increases in density from left to right. Our pixel density score is the index for which character to select from the string.

A Picture Paints a 1000 Words

halftone.png

Here's a more graphical description of the algorithm. After having converted the source image to grayscale, we select our first group of 3 by 3 pixels. Most likely not all 9 pixels will contain the same shade of grey, as represented in the first group in the image above.

Find the average colour for all nine pixels and quantize it, so that we only have 10 possible shades of grey as opposed to 256 shades of grey.

The calculated colour (0 to 9) tells us how many pixels to colour in the grid to achieve a halftone image, as shown by the top right grid in the image above.

Alternatively, if creating text art, this colour number becomes the index to select the output character from the halftone ascii string. In this case we should index the character "=".

At the end of the row we must also output a newline character.

The Code

The code is provided as is. It works satisfactorily with most images I have tried. Copy the code below into a file and save it with a .html extension. Then simply open the html file in a browser. To use the code click the "Choose File" button. Then if an image is loaded and displayed correctly (squashed vertically and in grayscale) click the "halftone" button (bottom left) to generate the text.


<html>

<head>

<script>

const IMAGE_WIDTH = 300;

var canvas = document.createElement("canvas");

var ctx = canvas.getContext("2d");

var imageData;


var condensed_data = [];


var text_art = " .:-=+*#%@";


var profiles = [[0, 0, 0, 0, 0, 0, 0, 0, 0],

[1, 0, 0, 0, 0, 0, 0, 0, 0],

[1, 1, 0, 0, 0, 0, 0, 0, 0],

[1, 1, 1, 0, 0, 0, 0, 0, 0],

[1, 1, 1, 1, 0, 0, 0, 0, 0],

[1, 1, 1, 1, 1, 0, 0, 0, 0],

[1, 1, 1, 1, 1, 1, 0, 0, 0],

[1, 1, 1, 1, 1, 1, 1, 0, 0],

[1, 1, 1, 1, 1, 1, 1, 1, 0],

[1, 1, 1, 1, 1, 1, 1, 1, 1]];


function my_setup() {

document.getElementById('myFile').onchange = function (evt) {

var tgt = evt.target || window.event.srcElement,

files = tgt.files;


// FileReader support

if (FileReader && files && files.length) {

var fr = new FileReader();

fr.readAsDataURL(files[0]);

fr.onload = () => showImage(fr);

}

}

}


function showImage(fileReader) {

var img = document.getElementById("myImage");

img.src = fileReader.result;

img.onload = () => getImageData(img);

}


function getImageData(img) {

document.getElementById('container').appendChild(canvas);


ratio = img.height / img.width;

new_height = Math.round(IMAGE_WIDTH * ratio)


new_height >>= 1;


padded_height = Math.ceil(new_height / 3) * 3;


canvas.width = IMAGE_WIDTH;

canvas.height = new_height;


ctx.drawImage(img, 0, 0, IMAGE_WIDTH, new_height);

imageData = ctx.getImageData(0, 0, IMAGE_WIDTH, new_height);

var i;

for (i = 0; i < imageData.data.length; i += 4) {

// 0.299 ∙ Red + 0.587 ∙ Green + 0.114 ∙ Blue


imageData.data[i+0] ^= 0xff;

imageData.data[i+1] ^= 0xff;

imageData.data[i+2] ^= 0xff;


 //for(var y=0; y< 3; ++y) {

//var val = imageData.data[i+0];

//val &= 0xe0;

var gsc = 0;

gsc += imageData.data[i+0] * 0.3;

gsc += imageData.data[i+1] * 0.59;

gsc += imageData.data[i+2] * 0.11;


imageData.data[i+0] = gsc;

imageData.data[i+1] = gsc;

imageData.data[i+2] = gsc;

 //}

 imageData.data[i+3] = 0xff;

}


// now make padded image, pad with red

canvas.height = padded_height;

ctx.fillStyle = 'red';

ctx.fillRect(0, 0, IMAGE_WIDTH, padded_height);


ctx.putImageData(imageData, 0, 0);


canvas.setAttribute('style', 'border: 2px solid blue');

}


function process33(segment) {

var sum = 0;

var elements = 0;

for(o = 0; o < segment.data.length; o += 4) {

sum += segment.data[o];

++elements;

}


if(elements) sum /= elements;


//sum >>= 4;


sum *= 9;

sum /= 255;


sum = Math.floor(sum);


var index = 0;


for(o = 0; o < segment.data.length; o += 4) {


dst = profiles[sum][index++];

if(dst) dst = 255;

segment.data[o+0] = dst;

segment.data[o+1] = dst;

segment.data[o+2] = dst;

}


document.getElementById("art").value += text_art[sum];


return segment;

}


function func_print() {

var x, y;


// reload image

ctx.fillStyle = 'white';

ctx.fillRect(0, 0, IMAGE_WIDTH, padded_height);


ctx.putImageData(imageData, 0, 0);


// process


document.getElementById("art").value = "";


for(y=0; y< canvas.height; y += 3) {

for(x=0; x< canvas.width; x += 3) {

segment = ctx.getImageData(x, y, 3, 3);

segment = process33(segment);

ctx.putImageData(segment, x, y);

}

document.getElementById("art").value += "\n";

}

}

</script>

</head>

<body onload="my_setup()">

<input type="file" id="myFile">Load image</input>

<br>

<img id="myImage" style="border:black 1px" />

<img id="myImage2" style="border:black 2px" />

<dir id='container'></dir>

<input type='button' onclick='func_print()' value='halftone'></input>

<textarea id="art" rows="100" cols="100">

</textarea>


</body>

</html>

Output

It is important that monospaced font be used, otherwise the effect is broken. Copy and paste the output text into a text editor where you can try out different fonts.

Feel free to modify the code, it is possible to use for example 5 by 5 pixel grids, giving you a total of 26 grayscale colours. However, you may need to scale the image to be larger, remember that you're using the average of a group of pixels, this has a slight blurring effect. Generally this is not a problem if your input image is large enough. Too large though will result in too much text. So you have to experiment to get the right ratio for your image. You will also notice that the output image is squashed vertically. This is because text font tends to be tall and narrow, which has the effect of stretching the image vertically. We have to first compress the image vertically to compensate for the stretching effect.

Understanding the Code

The core of the code was taken from online resources, which can be found by searching for things like opening an image in JavaScript.

When the image is loaded a function getImageData is called. This does several things. First - it inverts the input image data. This is not strictly necessary, but it is more natural to think of a higher pixel value representing more ink, and therefore being darker. Pixels on a computer screen however work the opposite way, the higher value means more light output and hence brighter. Instead of inverting the entire image it could have been quicker and computationally cheaper to just reverse our ascii character string. However, I decided to keep the string in it's original form as represented on websites dedicated to this topic.

The second thing this function does is convert a colour image to grayscale. Again the recommended method found online is to use the equation 0.30Red + 0.59Green + 0.11Blue.

The first element index 0, is red. index 1 is green, and finally index 2 is blue. In the code itself you will see we are reading 4 bytes at a time though, rather than 3. The fourth byte of data is alpha, or transparency - for our purpose we ignore this and just set it to 255 - not transparent at all. Though not used, the byte is there in the original image data, so we must read in 4 bytes at a time.

Inside func_print we take 3 by 3 blocks of pixels from the image and call function process33 to process the 3 by 3 block. This function determines the correct pixel density image to use, and also the correct ascii character to output to the text area.

A new line is inserted at the end of each row of characters.

Image dimensions are important, if you need to rescale the image or wish to process 5 by 5 blocks, make sure the width and height of the image are divisible by this number. Otherwise the function will attempt to index pixels that don't exist and will terminate with an error. Either catch the error, or dimension the image to avoid this issue.

I'm happy to help if you get stuck.


Function process33 is also responsible for quantizing the image. This is another thing you might wish to edit if you alter the grid size. As we are using 8 bits of data to represent the pixel brightness, the range of possible values is from 0 to 255. So taking a value, and dividing by 255 you are effectively changing the range from 0 to 1. Then by multiplying by 9 you are setting the range from 0 to 9. Which is what we want. Notice that in the code itself I first multiply by 9, then divide by 255, but the result is the same.

Thank You

Thank you for reading this instructable, I hope that you find it useful, or at least interesting. :)