다들 인터넷하면서 한번쯤 이런 이미지 본 적이 있으시죠?
확대해보면 아시다시피 저건 이미지가 아닌 ASCII 코드입니다.
댓글에서 종종 저런 텍스트를 보면 지나치기 일쑤였는데, 개발자가 된 지금은 어떻게 만들었을까? 라는 궁금증이 따라왔습니다.
마침 저는 Canvas API에 대한 지식이 어느정도는 있어서, 직접 개발해봤습니다.
다른분들도 Canvas에 재미를 붙였으면 하는 마음으로 제 경험을 공유해보려고 합니다!
여러색상으로 이루어진 이미지를 텍스트로 변환하려면 이미지가 여러개의 픽셀로 이루어져있다는 걸 알아야합니다.
이미지의 해상도가 가로 400 , 세로 800이면, 그 이미지는 400 x 800 = 320,000개의 픽셀로 이루어진거죠.
이렇게 설명하면 바로 감이 안오실거예요.
더 작은 이미지를 예시로 설명드릴게요.
가로 24, 세로 20 크기의 이미지입니다. 이미지에 나열되어있는 정사각형들이 픽셀입니다.
가로로 24개, 세로로 20개가 나열되어있고, 총 24 x 20 = 480개로 이루어져 있죠.
그리고 각 픽셀은 색을 가지고 있습니다. 그 색들이 모여서 이미지를 만듭니다.
그렇다는건, 픽셀을 조작하면 쉽게 이미지를 변환할 수 있다는 거죠.
예를들어 위 예시의 픽셀들 중 파란색 픽셀만 빨간색으로 바꾼다면 어떻게 될까요?
빨간 꼬부기가 되는겁니다!
그럼 이제 텍스트로 변환하는 법을 배워봅시다.
우리가 변환해야하는 아스키코드 이미지는 흑과 백으로만 이루어진 사진이죠.
흑과 백만으로는 색도 명암도 두 가지로 제한되기 때문에 이미지를 제대로 표현할 수 없습니다.
우리가 흔히 아는 흑백사진은 회색조(그레이스케일)라고 하는데요.

이런식으로 이미지를 표현합니다. 흑과 백만이 아닌 회색도 섞여있죠?
저게 바로 명암입니다. 흑과 백 사이의 색들로 명암을 표현한겁니다.
그런데 텍스트는 회색이 없고 검정색만 있는데 어떻게 명암을 표현할까요?
바로 여백입니다.
진한 테두리는 빽빽한 아스키코드로, 상대적으로 연하고 밝은 파란색은 여백이 많은 아스키코드로 변환됐습니다.
여백을 통해 명암이 표현되어 이미지가 잘보이네요.
자, 결론이 나왔습니다. 우리는 기존 이미지의 픽셀들을 밝을수록 여백이 많은, 어두울수록 여백이 없이 빽빽한 아스키코드로 변환하면 됩니다!
예를 들어 검정색 픽셀이면 '@', 흰색이면 ' ' 공백, 중간 밝기의 픽셀이면 ']' 으로 변환하면 됩니다.
어떻게 변환해야할지 알아봤으니 이제 코딩을 해보겠습니다.
일단 변환을 하려면 이미지의 픽셀을 알아야하고, 픽셀을 추출하려면 캔버스에 그려야합니다.
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = document.getElementById('my-image');
canvas.width = 200;
canvas.height = 200;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
가로 200px, 세로 200px 크기의 canvas를 생성하고 이미지를 그리는 로직입니다. drawImage의 첫번째 인자 타입은 여기를 참조해주세요.
예제에서는 img 엘리먼트를 사용하겠습니다.
const imageData = ctx.getImageData(0, 0, 200, 200);
const { data } = imageData;
이렇게 하면 캔버스 전체 이미지 데이터를 가져올 수 있습니다. (0,0좌표부터 200,200좌표에 해당하는 픽셀 데이터)
데이터 타입은 ImageData라는 타입으로 이 타입안에 data라는 프로퍼티가 바로 우리가 사용할 픽셀 데이터 입니다.
픽셀데이터는 Uint8ClampedArray 타입으로 모든 픽셀의 rgba값을 배열로 가지고 있습니다.
우리는 밝기에 맞게 픽셀들을 아스키코드로 변환해야합니다.
그럼 밝기는 어떻게 구할까요?
밝기를 구하기 위해 우리는 HSL이라는 색상모델을 사용할 겁니다.
HSL에서 L이 바로 Lightness, 우리가 구하려는 밝기 입니다.
우리가 픽셀에서 추출한 값은 rgb값이니 이걸 Lightness로 변경해야 합니다.

딱봐도 어려워보이는 공식이 있는데요, 우리는 맨 아래 L을 구하는 공식만 있으면 됩니다! [출처]
function calculateLightness(arr) {
const R = arr[0];
const G = arr[1];
const B = arr[2];
const fR = R / 255.0;
const fG = G / 255.0;
const fB = B / 255.0;
const cMax = Math.max(fR, fG, fB);
const cMin = Math.min(fR, fG, fB);
const lightness = (cMax + cMin) / 2;
const lightnessPercent = Math.round(lightness) * 100;
return lightnessPercent;
}
자바스크립트로 표현하면 다음과 같습니다. rgb값을 받아서 밝기를 구한다! 이게 끝입니다.
위 공식은 0~1 사이의 수를 도출하기 때문에 직관성을 위해 100을 곱해서 퍼센트로 변경해줍니다.
밝기를 구했으니 밝기에 맞는 아스키코드로 변경해야겠죠?
저는 이렇게 구현했습니다. 상세한 부분은 주석으로 설명하겠습니다.
// 밝기에 따라 변환할 아스키코드. 밝기 0% ~ 100%까지 순서대로 변환한다.
// ex) 밝기 5% => @, 밝기 20% => #
// 아스키코드는 밝기 정도에 따라 원하는걸로 고르셔도 됩니다!
const asciiArr = ['@', '$', '#', '&', '*', '=', '-', ',', '.', ' ', ' '];
const pixelData = getPixelData(imageData);
const result = lightness_to_ascii(pixelData);
function lightness_to_ascii(pixelData) {
let str = '';
pixelData.forEach((rgb, idx) => {
// 픽셀 데이터에서 밝기를 구한 후 밝기에 맞는 텍스트를 문자열에 더한다.
const lightness = calculateLightness(rgb);
// 밝기 퍼센테이지에 맞는 아스키코드를 index로 가져온다.
str += asciiArr[Math.floor(lightness / 10)];
// 캔버스 넓이만큼 한줄이 완성되면 다음줄로 줄바꿈
if ((idx + 1) % 넓이 === 0) str += '\n';
});
return str;
}
function getPixelData(imageData) {
// rgba데이터가 픽셀끼리 묶여있지 않기 때문에 묶어주는 작업
// a는 알파값으로 필요가 없기 때문에 반복문을 4씩 증가시켜서 건너뛴다.
for (let i = 0; i < imageData.length; i += 4) {
pixelData.push([imageData[i], imageData[i + 1], imageData[i + 2]]);
}
return pixelData;
}
주요하게 설명할 것은 rgba 데이터가
[
[255,255,255,0], // 0,0 좌표 픽셀 값(흰색)
[0,0,0,0], // 1,0 좌표 픽셀 값 (검정색)
...
]
이와같이 되어있지 않고
[255,255,255,0,0,0,0,0,...]
이런식으로 모든 픽셀이 하나의 배열로 합쳐있기 때문에 픽셀끼리 묶어주는 작업이 필요합니다.
묶어줄 필요없이 하나의 반복문에서 아스키코드 변환까지 할 수 있지만 여러분의 이해를 돕기 위해 로직을 분리했습니다.
해상도가 클수록(픽셀이 많을수록) 반복횟수가 많아지기 때문에 한번에 처리하는 것이 성능이 더 좋습니다.
참고로 rgba의 a는 알파채널로 투명도를 담당하는데, 필요하지않은 값이므로 걸러줍니다.
픽셀 데이터가 순서대로 정리되면, 해당 배열을 반복하면서 픽셀데이터를 아스키코드로 치환하는 작업을 진행합니다.
픽셀데이터는 좌표값(0,0)인 좌측 상단에서부터 우측으로 저장되어 있기 때문에, 반복문 순서대로 우측 끝까지 한줄이 그려지면 다음줄로 줄바꿈하는 작업이 필요합니다.
이제 아스키코드로 이루어진 문자열이 완성됐습니다. 해당 문자열은 줄바꿈이 있기 때문에 일반 html 태그에 text로 넣으면 줄바꿈이 되지 않습니다.
이렇게 준비해둔 pre태그에 넣어주면 의도한 모양으로 텍스트가 렌더링됩니다.
PRE태그.innerText = pixelData;
자세한 사용법은 깃허브에서 확인해주세요. [깃허브 링크]
export default class Convert {
asciiArr = ['@', '$', '#', '&', '*', '=', '-', ',', '.', ' ', ' '];
/**
*
* @param {string} imageId image element id
* @param {string} preId pre element id
* @param {number} width ascii code image width
* @param {number} height ascii code image height
*/
constructor(imageId, preId, width, height) {
this.IMG = document.getElementById(imageId);
this.PRE = document.getElementById(preId);
this.width = width;
this.height = height / 2;
this.startConvert();
}
startConvert() {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = this.width;
canvas.height = this.height;
ctx.drawImage(this.IMG, 0, 0, canvas.width, canvas.height);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const pixelData = this.getPixelData(imageData);
this.PRE.innerText = this.lightnessToAscii(pixelData);
}
/**
* convert lightness to ascii code
* @param {number[][]} pixelData
* @returns {string}
*/
lightnessToAscii(pixelData) {
let str = '';
pixelData.forEach((rgb, idx) => {
const lightness = this.calculateLightness(rgb);
str += this.asciiArr[Math.floor(lightness / 10)];
if ((idx + 1) % this.width === 0) str += '\n';
});
return str;
}
/**
* devide pixel data from canvas image data
* @param {number[]} imageData
* @returns {number[][]}
*/
getPixelData(imageData) {
const pixelData = [];
const { data } = imageData;
for (let i = 0; i < data.length; i += 4) {
pixelData.push([data[i], data[i + 1], data[i + 2]]);
}
return pixelData;
}
/**
* calculate lightness using RGB data
* @param {number[]} arr
* @returns {number}
*/
calculateLightness(arr) {
const R = arr[0];
const G = arr[1];
const B = arr[2];
const fR = R / 255.0;
const fG = G / 255.0;
const fB = B / 255.0;
const cMax = Math.max(fR, fG, fB);
const cMin = Math.min(fR, fG, fB);
const lightness = (cMax + cMin) / 2;
const lightnessPercent = Math.round(lightness) * 100;
return lightnessPercent
}
잘따라오셨나요? 흐름만 알면 canvas로 이미지를 조작하는 것은 꽤나 쉽습니다.
안내해드린 방법처럼 픽셀을 조작하면, 쉽게 필터를 구현할수도 있습니다.
저는 이 로직을 라이브러리로 만들어서 npm에 배포도 해봤습니다. [링크]
현재까지 1,282회의 다운로드를 기록하고 있는데요.
비록 적은 수지만 개발자로서의 보람을 느끼기엔 충분했습니다.
여러분도 canvas를 떠나서 아이디어가 있다면 주저하지말고 개발하세요.
그리고 여러분의 프로덕트와 스토리를 공유해보세요.
좋은 경험이 될거라 확신합니다.
구상 하시고 직접 구현해내신게 대단하다고 느껴집니다. 심지어 라이브러리 배포까지...ovob