Web Canvas Image Bilinear intepolation

Web Canvas Image 선형 보간

Canvas Image Pixel 은 ?

앞서 간단히 정리한 Web 에서 이미지를 다루는 방법은 Image Pixel을 직접 다룰 수 있는 Typed Array 를 Canvas 가 제공해 준다는 것입니다.
이미지가 100 x 100 정도의 size 라면 10000 개의 pixel 정보가 있고, 10000 개의 pixel 에는 각각 r, g, b, a 의 값이 각 1byte 씩 저장되어 (각 pixel당 4byte) 개발자가 접근할 수 있도록 데이터를 제공합니다. 어쩌면 이것이 Canvas 가 지닌 가장 강력한 도구가 아닌가 합니다.

Canvas에서 원본 이미지를 작게 혹은 크게 구성하려면 어떻게 해야할까요? 크기가 같다면 pixel 을 얻어 다른 곳에 똑 같은 크기로 copy 하면 될 것입니다.
하지만 작은 이미지에서 큰이미지로 변환해야 하는 일이 있다면 어떻게 해야 원본의 형태를 잘 유지하면서 옮길 수 있을까요 ? 이런 일은 WebGL 에서 texture 의 색상을 가져올 때 GPU 에서 진행되는 일입니다. 그것을 Canvas 에서 CPU 연산을 통해 정리해 보려고 합니다.
옮기는 방법은 크기만 비율로 키워 가장 근접한 색상을 선택하는 방법과, 인근한 4개의 Pixel 을 거리에 따라 비율로 색상을 섞는 방법이 있습니다.
그중 정리할 내용은 Bilinear Interpolation( 이중선형보간법 ) 입니다. 이름은 거창한데 주변 픽셀 4개를 기준으로 위치에 가까운 x 값을 기준으로 보간한 후, y 값을 기준으로 다시 보간하는 방법입니다. https://en.wikipedia.org/wiki/Bilinear_interpolation 에서 내용을 확인해 볼 수 있습니다.

본격적으로 이야기 하기전에 간단히 Web 에서 사용하는 색상 표현을 먼저 정리해 보겠습니다.

Web 에서 색상을 표현하는 방법

가장 일반적으로 색상을 표현하는 방법은 16진수 숫자를 문자열로 표현 하는 방법 입니다.
접두어로 “#” 을 붙이고 그 뒤에 FFFFFF => #FFFFFF 이런 방식으로 표현합니다. 16진수 F 가 10진수로는 15 에 해당 하니, FF 의 10 진수는 15 x 16 + 15 = 255 , 결국 255, 255, 255 의 색상값을 같는다는 표현입니다. 표현하고 있는 색은 빨간색(red), 녹색(green), 파란색(blue) , 경우에 따라 투명도(alpha) 까지 표현하기도 합니다. rgb 는 통상 0~255 사이의 값으로 이야기 하는데 0,0,0 이면 검은색, 255,255,255 이면 하얀색입니다. 그렇기 때문에 Web 에서 색상을 표현할 때 rgb (255,255,255) 라는 식으로 표현하기도 합니다. ( 색상을 0~255 로 구성하는게 일반적이지만, WebGL 등에서는 0~1 사이의 부동소수점 표현(float) 형식으로 표현하기도 합니다. )
어쩌면 많은 분들에게 너무 당연한 이야기를 반복한 이유는 이미지 색상을 Hex 에서 rgb 로 rgb 에서 hex 값으로 변환해야 할 때가 있기 때문입니다.

Hex To RGB 를 위한 Javascript

Validation 과 데이터를 가져오는 부분에서 정규식을 활용하여 가져오고 있습니다.
정규식은 그 자체로 정리가 필요한 부분이라, 자세히 이야기 하긴 어렵지만, 대략 공백을 제거한후 16진수 문자열을 가져오는 부분에 사용되고 있습니다.

16진수 표현은 ‘0x’ 를 접두어로 붙여서 구성하기 때문에 문자열을 조정후 shift 연산을 통해 각 단위의 값을 매핑하고 있습니다.

     export const convertHexToRgb = ( hexStr ) => {
         if ( !hexStr )
             return undefined;
         const hex = hexStr.replace(/\s|;/g,'').match(/^#([a-z|A-Z|0-9]{3,6})/i);
         if ( !hex || !hex[1] ) {
             return undefined;
         }
         let len = hex[1].length;
         let hStr = "0x";
         if ( len == 3 ) {
             let arr = hex[1].split('');
             hStr += arr[0]+arr[0]+arr[1]+arr[1]+arr[2]+arr[2];
         } else if ( len == 6 ) {
             hStr += hex[1];
         } else {
             return undefined;
         }
         return "rgb("+((hStr>>16)&255) + "," + ((hStr>>8)&255)+","+(hStr&255)+")";
     }

RGB 를 Hex 값으로 변형하기 위한 Javascript

위와 마찬가지로 정규식을 통해 수치 데이터를 추출한 후 해당 데이터를 16진수 문자열로 변형하고 있습니다.
( 아래의 정규식중 소수점을 가져오는 부분은 더 정확하게 해야 하는데 alpha 부분을 무시하는 로직이므로 간단하게 구성하였습니다. )

     export const convertRgbToHex = ( rgbStr ) => {
         if ( !rgbStr ) 
             return undefined;
         
         const rgb = rgbStr.replace(/\s|;/g,'').match(/^rgba?\(([\d]+),([\d]+),([\d]+)(,([\d.]+))?\)/i);
         if ( !rgb || !rgb.length )
             return undefined;
         let r = (rgb[1]&255);
         let g = (rgb[2]&255);
         let b = (rgb[3]&255);
         let a = (rgb[5] ? parseFloat(rgb[5]) : '');
         //	alpha ... skip ..
         return "#" + (r < 16 ? '0' : '') + r.toString(16) + (g < 16 ? '0' : '') + g.toString(16) + (b < 16 ? '0' : '') + b.toString(16); 
     };

기타 색상 관련 Utility 함수


     /**
      * max 는 포함안됨 ...
      * Range : min <= value < max 
      */
     export const getRandomIntValue = (min,max) => {
         return Math.floor(Math.random() * (max-min) + min);
     };

     export const getHtmlRamdonHexColor = () => {
         let r = getRandomIntValue(0,256);
         let g = getRandomIntValue(0,256);
         let b = getRandomIntValue(0,256); 
         return getHtmlHexColor(r,g,b);
     };


     export const getHtmlHexColor = (r,g,b) => {
         if ( isNaN(r) || isNaN(g) || isNaN(b)  ) 
             return undefined;
         
         r = (r < 0 ? 0 : (r > 255 ? 255 : r));
         g = (g < 0 ? 0 : (g > 255 ? 255 : g));
         b = (b < 0 ? 0 : (b > 255 ? 255 : b));        
         let colors = "#";
         if ( r < 16 ) {
             colors += "0";
         }
         colors += r.toString(16);
         if ( g < 16 ) {
             colors += "0";
         }
         colors += g.toString(16);
         if ( b < 16 ) {
             colors += "0";
         }
         colors += b.toString(16);
         return colors;
     };

Bilinear Interpolation 예시

동일한 크기의 이미지 픽셀에서 새롭게 이미지를 구성한다면, 같은 위치에 같은 Pixel 을 구성하면 될 것입니다.
조금 극단적인 가정으로 1 pixel 짜라 10 x 10 size 의 이미지 pixel 이 있다고 가정해 보겠습니다.
아마도 색상을 담고 있는 배열이 있다면 1차원 배열로 100 개의 색상값을 담는 그릇으로 구성될 수 있습니다. ( 물론 2차원 배열로도 가능합니다. )

이 색상 배열을 이용해서 크기를 확대해 비율로 가장 가까운 색상을 선택하는 Nearest 방식과, 주변 4개의 Pixel 정보를 혼합하여 표현하는 Bilinear Interpolation 에 의해 구성된 이미지를 확인해 보겠습니다.

임의의 색상 100 개 만들기


     const xSize = 10;
     const ySize = 10;
     const colorRgbArray = [];

     for ( let i = 0; i < ySize; i++ ) {
         for ( let j = 0; j < xSize; j++ ) {
             const cArray = [];
             for ( let t = 0; t < 3; t++ ) {
                 cArray.push(CanvasUtils.getRandomIntValue(0,256));
             }
             colorRgbArray.push(cArray);
         }
     }

xSize, ySize 가 10개씩 이니 random 하게 구성한 색상은 10 x 10 = 100 개의 색상 정보를 생성하였습니다.
getRandomIntValue(0, 256) 을 사용하면 0~255 사이의 값이 반환됩니다. ( max 값 미만 값만 반환하도록 함수를 구성하였습니다. )

색상값 단순 출력

원칙적으로 1 pixel 씩 10 x 10 인 이미지이지만, 이 경우 가독성이 너무 떨어져서 각 pixel 당 10 정도의 정방형 형태가 되도록 구성해 보았습니다.


     function makeOriginaCanvasColors(scale) {
         let scaleX = xSize*scale;
         let scaleY = ySize*scale;
         const canvas   = CanvasUtils.makeCanvasObject("originalCanvas", undefined, scaleX, scaleY);
         const ctx      = canvas.getContext("2d");

         const imageData = ctx.createImageData(scaleX, scaleY);
         for ( let r = 0; r < scaleY; r++ ) {
             let yPos = Math.floor(r/(scale));
             for ( let c = 0; c < scaleX; c++ ) {
                 let xPos = Math.floor(c/(scale));
                 let idx = yPos*xSize+xPos;
                 let cIdx = r*scaleX*4+c*4;
                 let t = 0;
                 for( ; t < 3; t++ ) {
                     imageData.data[cIdx+t] = colorRgbArray[idx][t];
                 } 
                 imageData.data[cIdx+t] = 255;
             }
         }
         ctx.putImageData(imageData,0,0);
     }

     makeOriginaCanvasColors(10);

그림을 그릴 때 canvas 의 내장 함수인 fillRect 를 통해 그릴 수도 있으나, pixel 단위로 그리는 것이 통일 성이 있을 듯 하여 pixel 을 증폭하여 그린 것입니다. 조금 주의 깊에 보아야 할 부분이 let yPos = Math.floor(r/(scale));, let xPos = Math.floor(c/(scale)); 에서 각 각 floor 함수를 사용 하고 있는 부분입니다.
scale 만큼 증폭시켰으니, 각 pixel 의 위치는 경계를 넘기 전까지 작은 pixel index 를 지칭하게 됩니다. 예를 들어 r = c = 9 인경우 10배 증폭하였으니, 9/10, 9/10 이면 소수점 절사 시켰을 때 위치는 0, 0 의 위치가 됩니다. 99, 99 의 위치는 9, 9 의 값을 가지게 되니 그리고자 하는 색상 pixel 을 가져올 수 있습니다.

10배 증폭한 원본이미지

Nearest 방식으로 가져오기

이제 표현하고자 하는 이미지 사이즈를 가로 500, 세로 500 의 그림으로 구성한다고 가정해 보겠습니다.
해당 위치에서 가장 가까운 pixel 의 값을 가져온다면, 0, 0 일 때 0, 0 의 색상값을, 499, 499 일 때 9, 9 의 색상을 가져올 수 있어야 합니다.
위와 동일한 방법으로 구성할 수도 있지만, Bilinear 방식은 주변 pixel 4개를 대상으로 합니다. 그것과 동일한 비율을 적용하려면 499, 499 의 위치일 때 색상 인덱스는 8, 8 이 되어야 x,y position 이 (8,8),(9,8), (8,9), (9,9) 가 구성될 수 있습니다.
이를 위해서 색상을 선택할 수 있는 간극을 size-1 만큼 줄인 값으로 나눠 크기를 한단위당 그릴 크기를 크게 해 줍니다. 시작점과, 끝점이 반에서 중간에서 시작하고, 끝나게 하는 효과가 나타납니다.


     const canvasWidth   = 500;
     const canvasHeight  = 500;

     const xGap          = canvasWidth/(xSize-1);
     const yGap          = canvasHeight/(ySize-1);


     function makeNearestCanvasColors() {

         const canvas   = CanvasUtils.makeCanvasObject("nearestCanvas", undefined, canvasWidth, canvasHeight);
         const ctx      = canvas.getContext("2d");

         const imageData = ctx.createImageData(canvasWidth, canvasHeight);
         for ( let r = 0; r < canvasHeight; r++ ) {
             let yPos = Math.round(r/(yGap));
             for ( let c = 0; c < canvasWidth; c++ ) {
                 let xPos = Math.round(c/(xGap));
                 let idx = yPos*xSize+xPos;
                 let cIdx = r*canvasWidth*4+c*4;
                 let t = 0;
                 for( ; t < 3; t++ ) {
                     imageData.data[cIdx+t] = colorRgbArray[idx][t];
                 } 
                 imageData.data[cIdx+t] = 255;
             }
         }
         ctx.putImageData(imageData,0,0);
     }

canvasWidth/(xSize-1);, canvasHeight/(ySize-1); 이 영역은 xGap, yGap 을 scale 보다 크게 만들어 줍니다.
let yPos = Math.round(r/(yGap));, let xPos = Math.round(c/(xGap)); 에서 중간보다 작을 경우 작은 index 를 그보다 클경우 바로 다음 값을 반환해 주고 있습니다.

Nearest 방식

Bilinear Interpolation

Round 는 반올림이기 때문에 중간값을 기준으로 위치가 달라집니다. Floor 는 절사 이기 때문에 바로 전 위치의 index 값을 얻을 수 있습니다.
게다가 원위치 에서 floor 한 값을 빼면 원위치에서 현재 위치가 얼마나 떨어져 있는지 delta 값을 알 수 있습니다. 예를 들어 4.789 를 절사(Floor)하면 4 가 나오고 그 delta 값은 0.789 이므로 처음 위치에서 0.789 비율로 다음 index 와 떨어져 있다는 의미가 됩니다.
이 부분이 두개 사이의 값을 보간하는 방식이 됩니다.
위의 예에서 첫번째 값은 (1-0.789) 정도로 반영되고, 그다음 인덱스의 값은 0.789 정도 반영된다는 의미라서 그 반영 비율로 두 값을 더하면 표현하고자 하는 값이 나온다는 것이 선형 보간의 가장 큰 특징입니다. 두번 한다는 것은 x 축 방향으로 아래 위 진행후 다시 y 축 방향으로 다시 한번 보간 한다는 의미 입니다.
소스를 보면 어렵지 않게 확인이 가능할것 같지만, 선형 보간은 간간히 보게 되는 걔념이라 간단히 예를 들어 정리해 보자면, 처음 시작이 100 이고, 다음 값이 200 이라고 가정해 보겠습니다. 어떤 점이 처음에서 0.4 떨어져 있다고 할 때 그 값은 100 * ( 1-0.4 ) + 200 * 0.4 = 60 + 80 = 140 입니다.
보통 y = x1 x ( 1 - t) + x2 x t 라고 표기하고 있습니다. 데이터를 예측하는 방식으로도 보간을 많이 사용하는데 그 기초가 선형 보간에서 시작되는 것 같습니다.


     function makeBilinearCanvasColors() {

         const canvas   = CanvasUtils.makeCanvasObject("bilinearCanvas", undefined, canvasWidth, canvasHeight);
         const ctx      = canvas.getContext("2d");

         const imageData = ctx.createImageData(canvasWidth, canvasHeight);
         for ( let r = 0; r < canvasHeight; r++ ) {
             let yPos = (r/yGap);
             let yFloor = Math.floor(yPos);
             let dy = yPos-yFloor;
             for ( let c = 0; c < canvasWidth; c++ ) {
                 let xPos = c/xGap;
                 let xFloor = Math.floor(xPos);
                 let dx = xPos - xFloor;
                 let idx01 = yFloor*xSize+xFloor;
                 let idx02 = idx01+1;
                 let idx03 = (yFloor+1)*xSize+xFloor;
                 let idx04 = idx03+1;

                 let colors = CanvasUtils.makeBilinearColors(dx,dy,colorRgbArray[idx01],colorRgbArray[idx02], colorRgbArray[idx03], colorRgbArray[idx04] );
                 let cIdx = r*canvasWidth*4+c*4;
                 let t = 0;
                 for( ; t < 3; t++ ) {
                     imageData.data[cIdx+t] = colors[t];
                 } 
                 imageData.data[cIdx+t] = 255;
             }
         }
         ctx.putImageData(imageData,0,0);
     }


     /**
      * position :   c01             c02
      *                  dx,dy
      *              c03             c04
      * @param {*} dx : 단위 1을 기준으로 dx + 1- dx = 1, dx 는 c01 과 c02 사이의 c01 에서 떨어진 비율, 0 이면 c01 값이고 1이면 c02 값이 됩니다. ( c03, c04 동일)
      * @param {*} dy : 단위 1 기준 dx 와 동일한 로직 
      * @param {*} c01 
      * @param {*} c02 
      * @param {*} c03 
      * @param {*} c04 
      */
     export const makeBilinearColors = ( dx, dy, c01, c02, c03, c04 ) => {
         const firstRow = [0,0,0];
         const secondRow = [0,0,0];
         const result = [0,0,0];
         for ( let i = 0; i < 3; i++ ) {
             firstRow[i] = (c01[i]*(1-dx) + c02[i]*(dx));
             secondRow[i] = (c03[i]*(1-dx) + c04[i]*(dx));            
         }

         for ( let i = 0; i < 3; i++ ) {
             result[i] = Math.round(firstRow[i]*(1-dy)+secondRow[i]*(dy));
             result[i] = (result[i] < 0 ? 0 : (result[i] > 255 ? 255 : result[i]));
         }
         return result;
     };

WegGL 을 보다 보면, Texture 영역에서, Nearest, Bilinear 등의 용어가 제공하는 함수에 나오고 있습니다.
GPU 에서는 개발자가 개입하지 않아도, 함수를 선택하면 위 과정이 자동으로 수행됩니다.
해당 내용을 정리해 두면, 유사한 다른 분야에서도 좀더 깊은 이해도를 가지고 볼 수 있지 않을 까 해서 정리해 보았습니다.

Bilinear 방식

다음은 위의 예시 결과 입니다.
해당 링크에서도 확인이 가능하시지만, Random 이기 때문에 접속할 때 마다 모습은 동일 하지 않을 것 같습니다.

 Share!