La foret rouge
Published on

다크모드 → 라이트모드 밝기 보정

Authors
  • avatar
    Name
    신주용

이전 배포를 통해 서버와의 통신 없이 브라우저 상에서 다크 모드 이미지를 라이트 모드로 변환하는 기능과 밝기를 조절할 수 있는 기능까지 추가했습니다.

문제 인식

하지만 여기서 한 가지 문제를 발견했습니다. 색이 뭔가 칙칙한 느낌입니다.

이 문제를 해결하기 위해 밝기를 수정할 수 있는 커밋을 올렸습니다.

Jimp.read(imgPath).then((image) => {
  image.scan(...) {
    // 1. convert rgb to hsl
    // 2. invert l channel
    // 3. revert hsl to rgb
    // ...
  }
);

// 4. additional processing
if (brightness !== 0) {
  image.brightness(brightness);
}

그런데 여기서 문제는, 밝기를 올리면 어두운 부분도 같이 밝아지면서 이미지가 뿌옇고 글씨가 또렷하지 않게 됩니다.

원인 분석

brightness 함수를 쓰는게 맞을까?

위에서 이미지의 밝기를 조절하기 위해 Jimp의 brightness 함수를 사용했습니다. 해당 함수의 구현은 다음과 같이 되어 있습니다.

export default () => ({
  /**
   * Adjusts the brightness of the image
   * @param {number} val the amount to adjust the brightness, a number between -1 and +1
   * @param {function(Error, Jimp)} cb (optional) a callback for when complete
   * @returns {Jimp }this for chaining of methods
   */
  brightness(val, cb) {
    // ...
    this.scanQuiet(
      0,
      0,
      this.bitmap.width,
      this.bitmap.height,
      function (x, y, idx) {
        if (val < 0.0) {
          this.bitmap.data[idx] *= 1 + val;
          this.bitmap.data[idx + 1] *= 1 + val;
          this.bitmap.data[idx + 2] *= 1 + val;
        } else {
          this.bitmap.data[idx] += (255 - this.bitmap.data[idx]) * val;
          this.bitmap.data[idx + 1] += (255 - this.bitmap.data[idx + 1]) * val;
          this.bitmap.data[idx + 2] += (255 - this.bitmap.data[idx + 2]) * val;
        }
      }
    );
    // ...

이 코드에서 lightgrey(#D3D3D3, rgb(211,211,211)), grey(#808080, rgb(128,128,128)), 이름은 없지만 꽤 어두운 회색(#282828, rgb(40,40,40))이 어떻게 바뀌는지 비교해봅시다.

잠시 추가 설명을 하자면 우리가 일반적으로 사용히는 이미지는 8bit = 28 2^8 = 256 단계로 색을 표현합니다. 때문에 검은색은 (0,0,0), 흰색은 (255,255,255), 빨간색은 (255,0,0), 빨강 + 파랑인 보라색은 (255,0,255)와 같이 나타낼 수 있습니다. 아래 표에서 RGB 코드는 0부 회색이라 rgb 값이 같으므로 편의상 하나만 표시하겠습니다.

NameOriginalBrightness
0.2
Brightness
0.5
Brightness
0.8
lightgrey211219.8
(Δ=8.8)
233
(Δ=22)
246.2
(Δ=35.2)
grey128153.4
(Δ=25.4)
191.5
(Δ=63.5)
229.6
(Δ=101.6)
어두운 회색4083
(Δ=43)
147.5
(Δ=107.5)
212
(Δ=172)

this.bitmap.data[idx] += (255 - this.bitmap.data[idx]) * val; 이 줄이 문제의 원인입니다. 255에서 뺀 값을 사용하기 때문에 원래 밝았던 부분은 그렇게 많이 증가하지 않고 (덜 밝아지고) 원래 어두웠던 부분은 많이 밝아집니다. 때문에 brightness()를 사용해 수정한 이미지는 뿌옇게 보였던 것입니다.

반면, color() 함수를 사용해 lighten으로 밝기를 조절하는 방법도 있습니다. 이는 다음과 같이 구현되어 있습니다.

function colorFn(actions, cb) {
  // ...
  this.scanQuiet(0, 0, this.bitmap.width, this.bitmap.height, (x, y, idx) => {
    // ...
    const colorModifier = (i, amount) =>
      this.constructor.limit255(clr[i] + amount);

    actions.forEach((action) => {
      // ...
      else {
        // ...
        clr = tinyColor(clr);
        // ...
        clr = clr[action.apply](...action.params).toRgb();
      }
    });
    // ...

tinyColor라는 외부 라이브러리를 사용해서 처리하고 있네요. 그러면 tinyColor의 lighten 함수는 어떻게 구현되어 있을까요?

! function(t, r) { ... }(this, (function() {
  // ...
  function l(t, r) {
    r = 0 === r ? 0 : r || 10;
    var e = n(t).toHsl();
    return e.l += r / 100, e.l = k(e.l), n(e)
  }
  // ...
  n.prototype = {
    // ...
    _applyModification: function(t, r) {
      var e = t.apply(null, [this].concat([].slice.call(r)));
      return this._r = e._r, this._g = e._g, this._b = e._b, this.setAlpha(e._a), this
    },
    // ...
    lighten: function() {
      return this._applyModification(l, arguments)
    },
    // ...
  }
  // ...
  function k(t) {
    return Math.min(1, Math.max(0, t))
  }
  // ...

제가 hsl로 바꾸고 l 채널을 조절해 밝기를 바꾼 방법과 동일한 방법임을 알 수 있습니다. 이렇게 하면 기존 픽셀의 값이 얼마든 상관없이 e.l 채널 값에 동일한 r / 100 값이 더해집니다. 추가적으로 여기에서는 값을 (0,1) 범위로 다루었고, l 채널에 0.? 단위로 밝기를 조절한 후, k() 함수로 범위가 넘어가는 경우를 방지했습니다.

brigntness를 바꾸는 기존 방법이 잘못된 구현은 아닙니다. 분명 저렇게 밝기를 바꾸는 것이 필요한 경우도 있겠죠. 하지만 제 경우에는 적합하지 않기 떄문에 lighten()을 사용하도록 변경하였습니다.

다크 모드의 배경은 검지 않다?

일반적으로 라이트 모드에서는 배경색으로 흰색(#FFFFFF)을 사용합니다. 그러면 다크 모드에서는 검은색을 배경으로 쓰지 않을까요?

Material은 다크 모드의 배경색으로 검은색 대신 어두운 회색을 사용할 것을 권합니다1. 또한, 밝은 색과 어두운 색의 차이인 대비(contrast)의 권장 비율도 언급하며, Apple은 충분한 대비만을 권장합니다2.

많이 사용하는 다크 모드 테마인 Atom One Dark나 Monokai도 배경색은 검은 색이 아닌 #282c34(One Dark), #272822(Monokai)을 사용 중이라는 것을 알 수 있습니다34.

이 문제를 해결하기 위해 l 채널 반전 시 기본적으로 밝기를 20% 정도 밝게 하도록 했고, 이 수치는 Heuristic수작업하게 얻은 값입니다.

이 부분을 조금 더 깊이 들어가 보자면 몇 가지 가정을 바탕으로 조금 더 발전된 밝기 변환 로직(라이트 모드에서는 흰 바탕에 검은 글씨로 가정 -> 다크모드 이미지에서 가장 밝은 값, 가장 어두운 값을 찾고 -> 선형 또는 다차함수를 사용해 비례적으로 밝기 수정)을 작성할 수도 있을 것입니다. 하지만 지금은 단순화한 과정을 거친 이미지가 크게 문제가 있어보이지 않고, 이런 fine tuning보다는 사용자가 이미지를 수정할 수 있는 다른 기능을 더 제공하는 편이 나을 것 같다고 판단하였습니다.

captured2l
밝기 수정 결과 이미지

수정 커밋

  1. 밝은 영역보다 어두운 영역을 더 밝히는 brightness 수정이 아니라 전 영역을 동일하게 밝게 하는 lighten() 함수로 수정
  2. 기본적으로 살짝 밝혀서 배경색 보완

이 커밋은 깃허브에서 확인할 수 있습니다.

Footnotes

  1. "Dark theme." m2.material.io. https://m2.material.io/design/color/dark-theme.html#properties (accessed Jun. 10, 2023)

  2. "Dark Mode." developer.apple.com. https://developer.apple.com/design/human-interface-guidelines/dark-mode#Dark-Mode-colors (accessed Jun. 15, 2023)

  3. snowsonic. "Atom One Dark 2 Color Palette." www.color-hex.com. https://www.color-hex.com/color-palette/1017620 (accessed Jun. 15, 2023)

  4. "vscode/extensions/theme-monokai/themes /monokai-color-theme.json." github.com. https://github.com/microsoft/vscode/blob/main/extensions/theme-monokai/themes/monokai-color-theme.json#L19 (accessed Jun. 15, 2023)