La foret rouge
Published on

JavaScript에서 이미지 RGB, HSL 변환

Authors
  • avatar
    Name
    신주용

색 모형?

영어로는 Colour Model, 또는 Colour Space라고 합니다. 쉽게 설명하자면, 어떤 정보를 사용해 색을 표현할지를 나타내는 방법입니다. 우리가 흔히 들어본 RGB는 Red(빨강), Green(초록), Blue(파랑)라는 빛의 3원색 이론을 기반으로 이들의 양을 조합해 색을 나타내는 방식입니다. 스마트폰, TV 등 현대의 대부분의 매체는 빛을 내는 화면이 있기 때문에 우리에게 가장 익숙한 방식이 RGB입니다.

하지만, RGB 방식의 이미지에서는 색 정보와 밝기 정보가 같은 변수를 사용해 조절됩니다. 그렇기 때문에 이미지를 단순하게 반전시키면 이상한 색으로 바뀌게 됩니다.

captured2l

CaptureD2L에서는 색상 정보는 유지하고 밝기만 반전시키기 위해 HSL 색 모형을 활용했습니다1. 빨간색, 초록색, 파란색의 양을 조절해 색과 밝기를 조절하는 RGB와는 달리 HSL 색 모형에서는 색 정보인 Hue(색조), Saturation(채도)과 밝기 정보인 Lightness 채널이 분리되어 있으므로 색 정보는 그대로 유지한 채 밝기만 반전 시킬 수 있습니다.

captured2l

이미지 처리

CaptureD2L의 첫 버전에서는 Python REST API 서버를 두고, OpenCV를 사용해 이미지 처리를 하는 구조를 택했습니다. 당시에는 제가 FE, JS에 익숙하지 않았기 때문에 빠르게 아이디어를 실현하기 위해 이미지 처리 서버를 따로 뒀습니다. 하지만 아무래도 사용자 브라우저에서 FE 만으로도 이미지 처리가 가능하다면 굳이 비용이 큰 네트워크 통신을 거칠 필요가 없을 것입니다.

그래서 자바스크립트에서 다음과 같은 조건에 부합하는 이미지 처리 라이브러리를 몇 가지 찾아봤습니다.

  1. Client-side에서 모든 동작이 가능할 것
  2. 유지 보수가 최근까지 지속 되었거나 커뮤니티가 어느 정도 있을 것
SharpCamanJSJimp
HSL변환OOX
Client-side
가능?
X
(for Node.js)
OO
마지막 커밋2023.06.2020.02.
(No longer
maintained)
2023.05.
  • Gatsby에서 Sharp를 지원해서 Client-side에서 동작 가능한 줄 알았는데, 정적 사이트 빌드 시에 동작하나 봅니다. 그래서 Sharp는 패스.
  • CamanJS는 더이상 유지보수가 안되므로 패스.
  • Jimp는 HSL 변환이 없다는 단점이 있지만 비교적 최근까지 유지보수가 되고 있고, 브라우저에서 사용 가능한 것으로 보입니다. 그래서 최종적으로는 Jimp를 사용하되, 색 모형 변환은 직접 구현을 하기로 정했습니다.

색 모형 변환

이미지 읽기

우선, 이미지를 Jimp로 읽어야 합니다.

import Jimp from 'jimp'

Jimp.read(imgPath)
  .then((image) => {
    console.log(image)
  })
  .catch((error) => {
    console.error(error)
  })

Jimp에서는 image.scan() 함수를 이용해 픽셀별로 값을 조작할 수 있습니다. 그래서 변환 과정은 다음과 같이 진행됩니다.

import Jimp from 'jimp'

Jimp.read(imgPath)
  .then((image) => {
    image.scan(0, 0, image.bitmap.width, image.bitmap.height, function (x, y, idx) {
      // 1. convert rgb to hsl
      const hsl = rgb2hsl(
        image.bitmap.data[idx + 0], // r channel
        image.bitmap.data[idx + 1], // g
        image.bitmap.data[idx + 2] // b
      )

      // 2. do something

      // 3. re-convert hsl to rgb
      const rgb = hsl2rgb(hsl.h, hsl.s, hsl.l)

      image.bitmap.data[idx + 0] = rgb.r
      image.bitmap.data[idx + 1] = rgb.g
      image.bitmap.data[idx + 2] = rgb.b
    })
  })
  .catch((error) => {
    console.error(error)
  })

RGB → HSL

그리고 변환 공식을 알려 주는 사이트를 참고해 RGB 값을 HSL 값으로 변환해보는 코드를 작성해보겠습니다.

R=R/255G=G/255B=B/255M=max(R,G,B)m=min(R,G,B)d=MmR' = R / 255 \quad G' = G / 255 \quad B' = B / 255 \\ M = max(R', G', B') \\ m = min(R', G', B') \\ d = M - m \\
H={0°d==060°×(GBdmod6)M==R60°×(BRd+2)M==G60°×(RGd+4)M==BH = \begin{cases} 0\degree & d == 0 \\ 60\degree \times \lparen \frac{G' - B'}{d} \bmod 6 \rparen & M == R' \\ 60\degree \times \lparen \frac{B' - R'}{d} + 2 \rparen & M == G' \\ 60\degree \times \lparen \frac{R' - G'}{d} + 4 \rparen & M == B'\\ \end{cases}
L=(M+m)/2L = (M + m) / 2
S={0d==0d12L1d !=0S = \begin{cases} 0 & d == 0 \\ \frac{d}{1 - \vert 2L - 1 \vert} & d \text{ !=} 0 \\ \end{cases}
const rgb2hsl = (r: number = 0, g: number = 0, b: number = 0) => {
  r /= 255, g /= 255, b /= 255;
  const M = Math.max(r, g, b), m = Math.min(r, g, b);
  const d = M - m;

  const hsl = {
    h: 0,
    s: (d == 0) ? 0 : (d / (1 - Math.abs(M + m - 1))),
    l: (M + m) / 2
  };

  if (d == 0) {
  } else if (M == r) {
    hsl.h = 60 * (((g - b) / d) % 6)
  } else if (M == g) {
    hsl.h = 60 * (((b - r) / d) + 2)
  } else if (M == b) {
    hsl.h = 60 * (((r - g) / d) + 4)
  }

  return hsl;
}

밝기 채널 반전

색 정보는 유지하고 Lightness 채널을 반전시켜 다크모드 테마를 라이트모드로 바꿔줍니다. 8bit 이미지에서 가장 밝은 값은 28=2562^8 = 256이고, 0부터 255까지를 사용하므로 255에서 기존 l채널 값을 빼주면 반전이 됩니다.

Jimp.read(imgPath).then((image) => {
  image.scan(0, 0, image.bitmap.width, image.bitmap.height, function (x, y, idx) {
    // 1. convert rgb to hsl
    const hsl = rgb2hsl( ... )

    // 2. invert l channel
    hsl.l = 255 - hsl.l;

    // ...

HSL → RGB

다시 원래대로 돌리는 과정입니다. 이번엔 사이트를 참고하여 HSL을 RGB로 변환하는 함수를 구현합니다.

C=(12L1)×SC = (1 - \vert 2L - 1 \vert) \times S
X=C×(1(H/60°)mod21)X = C \times (1 - \vert(H / 60\degree) \bmod 2 - 1 \vert)
m=LC/2m = L - C / 2
(R,G,B)={(C,X,0)0°H<60°(X,C,0)60°H<120°(0,C,X)120°H<180°(0,X,C)180°H<240°(X,0,C)240°H<300°(C,0,X)300°H<360°(R', G', B') = \begin{cases} (C, X, 0) & 0\degree \le H \lt 60\degree \\ (X, C, 0) & 60\degree \le H \lt 120\degree \\ (0, C, X) & 120\degree \le H \lt 180\degree \\ (0, X, C) & 180\degree \le H \lt 240\degree \\ (X, 0, C) & 240\degree \le H \lt 300\degree \\ (C, 0, X) & 300\degree \le H \lt 360\degree \\ \end{cases}
R=(R+m)×255G=(G+m)×255B=(B+m)×255R = (R' + m) \times 255 \quad G = (G' + m) \times 255 \quad B = (B' + m) \times 255
const hsl2rgb = (h: number = 0, s: number = 0, l: number = 0) => {
  const C = (1 - Math.abs(2 * l - 1)) * s;
  const X = C * (1 - Math.abs((h / 60) % 2 - 1));
  const m = l - (C / 2);

  let R = 0, G = 0, B = 0;
  if (0 <= h && h < 60) {
    R = C, G = X;
  } else if (60 <= h && h < 120) {
    R = X, G = C;
  } else if (120 <= h && h < 180) {
    G = C, B = X;
  } else if (180 <= h && h < 240) {
    G = X, B = C;
  } else if (240 <= h && h < 300) {
    R = X, B = C;
  } else if (300 <= h && h < 360) {
    R = C, B = X;
  }

  return {
    r: Math.trunc((R + m) * 255),
    g: Math.trunc((G + m) * 255),
    b: Math.trunc((B + m) * 255),
  };
}

Result

video: https://www.youtube.com/embed/_jxirP13o-A

결과적으로는 외부 API 호출 없이 브라우저에서 JS만으로 밝기 반전을 통한 다크모드 → 라이트 모드 변환을 할 수 있었습니다.

이 변경사항의 자세한 내용은 깃허브 커밋을 확인해주세요.

Footnotes

  1. Shin Juyong. "CaptureD2L 프로젝트 소개." cheesecat47.github.io. https://cheesecat47.github.io/posts/2023/02/11/introduce-cd2l/ (accessed Jun. 7, 2023)