As the other answers have already pointed out, the technique to use ASCII shade characters to generate more colors from the 16 base colors is called dithering. Dithering comes at the cost of some image resolution. Also see the legendary 8088 Corruption / 8088 Domination programs.
I'd like to provide you some code on how to find the color pair and dithering shade character algorithmically. The below approach works both in the Windows/linux consoles as well as over SSH and in the Linux Subsystem for Windows.
The general procedure is:
- scale the source image down to the console resolution
- map the color of each pixel to the console color that matches best
- draw block/shade characters with the selected color
As a test image, I use a HSV color map:

At first, here is 16 colors at double vertical resolution. With the block character (char)223
(▀), you can double the vertical resolution by using text/background color to draw the upper and lower half of every character independently. For matching the color, I use the distance vector between the target and the probe color rgb components and brute force test all of the 16 different colors. The function sq(x)
returns the square x*x
.
int get_console_color(const int color) {
const int r=(color>>16)&255, g=(color>>8)&255, b=color&255;
const int matches[16] = {
sq(r- 0)+sq(g- 0)+sq(b- 0), // color_black 0 0 0 0
sq(r- 0)+sq(g- 55)+sq(b-218), // color_dark_blue 1 0 55 218
sq(r- 19)+sq(g-161)+sq(b- 14), // color_dark_green 2 19 161 14
sq(r- 58)+sq(g-150)+sq(b-221), // color_light_blue 3 58 150 221
sq(r-197)+sq(g- 15)+sq(b- 31), // color_dark_red 4 197 15 31
sq(r-136)+sq(g- 23)+sq(b-152), // color_magenta 5 136 23 152
sq(r-193)+sq(g-156)+sq(b- 0), // color_orange 6 193 156 0
sq(r-204)+sq(g-204)+sq(b-204), // color_light_gray 7 204 204 204
sq(r-118)+sq(g-118)+sq(b-118), // color_gray 8 118 118 118
sq(r- 59)+sq(g-120)+sq(b-255), // color_blue 9 59 120 255
sq(r- 22)+sq(g-198)+sq(b- 12), // color_green 10 22 198 12
sq(r- 97)+sq(g-214)+sq(b-214), // color_cyan 11 97 214 214
sq(r-231)+sq(g- 72)+sq(b- 86), // color_red 12 231 72 86
sq(r-180)+sq(g- 0)+sq(b-158), // color_pink 13 180 0 158
sq(r-249)+sq(g-241)+sq(b-165), // color_yellow 14 249 241 165
sq(r-242)+sq(g-242)+sq(b-242) // color_white 15 242 242 242
};
int m=195075, k=0;
for(int i=0; i<16; i++) if(matches[i]<m) m = matches[k=i];
return k;
}

The 16 colors are quite a limitation. So the workaround is dithering, mixing two colors to get better colors at the cost of image resolution. I use the shade characters (char)176
/(char)177
/(char)178
(Windows) or \u2588
/\u2584
/\u2580
(Linux); these are represented as (░/▒/▓). In the 12x7 font size that I use, the color mix ratios are 1:6, 2:5 and 1:2 respectively. To find the mixing ratios for your font setting, print the three shade characters in the console, take a screenshot, zoom in and count the pixels.
The three different shade ratios turn the 16 base colors into a whopping 616 colors, not counting duplicates. For matching the closest color, I first mix the colors with the shade character ratios, then compute the distance vector of target to probe rgb color components and brute force this for all probe color combinations. To encode which shade character is used and which two colors are foreground and background colors, I use bit shifting to get it all into one int
return value.
int get_console_color_dither(const int color) {
const int r=(color>>16)&255, g=(color>>8)&255, b=color&255;
const int red [16] = { 0, 0, 19, 58,197,136,193,204,118, 59, 22, 97,231,180,249,242};
const int green[16] = { 0, 55,161,150, 15, 23,156,204,118,120,198,214, 72, 0,241,242};
const int blue [16] = { 0,218, 14,221, 31,152, 0,204,118,255, 12,214, 86,158,165,242};
int m=195075, k=0;
for(int i=0; i<16; i++) {
for(int j=0; j<16; j++) {
const int mixred=(red[i]+6*red[j])/7, mixgreen=(green[i]+6*green[j])/7, mixblue=(blue[i]+6*blue[j])/7; // (char)176: pixel ratio 1:6
const int match = sq(r-mixred)+sq(g-mixgreen)+sq(b-mixblue);
if(match<m) {
m = match;
k = i<<4|j;
}
}
}
for(int i=0; i<16; i++) {
for(int j=0; j<16; j++) {
const int mixred=(2*red[i]+5*red[j])/7, mixgreen=(2*green[i]+5*green[j])/7, mixblue=(2*blue[i]+5*blue[j])/7; // (char)177: pixel ratio 2:5
const int match = sq(r-mixred)+sq(g-mixgreen)+sq(b-mixblue);
if(match<m) {
m = match;
k = 1<<8|i<<4|j;
}
}
}
for(int i=0; i<16; i++) {
for(int j=0; j<i; j++) {
const int mixred=(red[i]+red[j])/2, mixgreen=(green[i]+green[j])/2, mixblue=(blue[i]+blue[j])/2; // (char)178: pixel ratio 1:2
const int match = sq(r-mixred)+sq(g-mixgreen)+sq(b-mixblue);
if(match<m) {
m = match;
k = 2<<8|i<<4|j;
}
}
}
return k;
}
Finally, you extract the shade character and the two colors by bit shifting and bit masking:
const int dither = get_console_color_dither(rgb_color);
const int textcolor=(dither>>4)&0xF, backgroundcolor=dither&0xF;
const int shade = dither>>8;
string character = ""
switch(shade) {
#if defined(_WIN32)
case 0: character += (char)176; break;
case 1: character += (char)177; break;
case 2: character += (char)178; break;
#elif defined(__linux__)
case 0: character += "\u2591"; break;
case 1: character += "\u2592"; break;
case 2: character += "\u2593"; break;
#endif // Windows/Linux
}
print(character, textcolor, backgroundcolor);
The print(...)
function is provided here. The resulting image looks like this:

Finally, no asciiart post is complete without the Lenna test image. This shows you what to expect from dithering.
