ADJUSTMENT ENGINE / LEVELS

무료 포토샵 개발기 3편: 레벨 계산·손잡이·히스토그램의 기준 맞추기

픽셀 계산이 맞아도 손잡이와 그래프가 같은 의미를 전달하지 않으면 도구는 불완전합니다. 고정 소스 재현과 실제 저장·재열기로 세 부분을 함께 확인했습니다.

Abstract

레벨의 입력 검정점·감마·흰색점은 하나의 변환식에 연결됩니다. 이 글은 9월 18일 계산 엔진과 9월 22일 히스토그램 제어의 개발 기록을 구분하고, 공개 revision의 감마 왕복 18조건·과거 회색 출력 9개를 재현합니다. 새 브라우저 검증은 로컬에서 제공한 배포 파일로 수행했으며, 과거 Photoshop 전체 차트 비교를 오늘 다시 실행한 것은 아닙니다.

1. 목표: 계산과 조작부가 같은 기준을 쓰게 하기

Photoshot은 브라우저에서 동작하는 독립 사진 편집기입니다. 이 연재의 “무료 포토샵”은 익숙한 작업을 웹에서 구현한다는 맥락이며 Adobe Photoshop의 무료 배포를 뜻하지 않습니다. 3편의 목표는 레벨 조정의 숫자·손잡이·분포 그래프가 같은 입력을 설명하도록 만드는 것입니다.

입력 검정점과 흰색점은 사용할 구간을 정하고, 감마는 그 사이의 중간톤을 바꿉니다. 출력 검정점과 흰색점은 최종 범위를 정합니다. RGB 공통 변환 뒤 각 채널 변환을 적용하고, 알파·마스크·레이어 합성은 기존 조정 레이어 구조가 맡습니다.

2. 초기 구현과 부족했던 설명

보존된 9월 18일 기록에는 조정 엔진과 숫자 패널 연결이 먼저 등장합니다. 9월 22일에는 실제 이미지 분포와 검정·중간·흰색 손잡이를 레벨 패널에 연결했습니다. 처음의 계산식과 나중의 조작부를 같은 시점에 완성된 기능처럼 합쳐 쓰지 않았습니다.

숫자 입력만 있으면 현재 사진에서 어두운 값과 밝은 값이 얼마나 분포하는지 읽기 어렵습니다. 그래프를 붙일 때도 “밝기 분포”와 “RGB 채널 값 분포”를 섞으면 다른 대상을 같은 그래프처럼 보게 됩니다. 계산이 있다는 것과 사용자가 계산을 해석할 수 있다는 것은 별도의 과제였습니다.

3. 실패 조건을 수치로 드러내기

감마 손잡이를 검정점과 흰색점의 단순한 정중앙에 고정하면 감마 1에서만 맞습니다. 검정 0·흰색 255·감마 2일 때 입력 127.5는 정규화 출력 약 0.707이 됩니다. 출력 0.5를 나타내는 손잡이라면 입력 63.75에 있어야 합니다. 이 비교는 이번에 계산한 잘못된 대안식의 진단이며, 보존되지 않은 과거 오류 화면을 재현했다고 주장하지 않습니다.

히스토그램에는 별도의 반례를 사용했습니다. 불투명한 순수 빨강 픽셀 하나의 가중 밝기 bin은 54입니다. 같은 픽셀의 RGB 결합 분포는 bin 0에 2/3, bin 255에 1/3이 들어갑니다. 완전히 투명한 두 번째 픽셀은 두 분포 모두에 기여하지 않습니다. 이 차이를 없애면 채널 선택의 의미가 무너집니다.

감마별 응답과 출력 0.5를 나타내는 손잡이 위치. 입력 범위 0~255, 출력 범위 0~1의 결정적 계산이며 측정 사진이 아닙니다.
Fig. 1 | 감마별 응답과 출력 0.5를 나타내는 손잡이 위치. 입력 범위 0~255, 출력 범위 0~1의 결정적 계산이며 측정 사진이 아닙니다.
불투명 빨강 1픽셀과 완전 투명 1픽셀의 실제 소스 계산. A는 가중 밝기 bin, B는 채널 평균 분포입니다.
Fig. 2 | 불투명 빨강 1픽셀과 완전 투명 1픽셀의 실제 소스 계산. A는 가중 밝기 bin, B는 채널 평균 분포입니다.

4. 고정 GitHub 출처와 직접 구현한 연결

GIMP의 Levels 공식 문서는 입력·출력 범위와 중간톤의 의미를 확인한 개념 자료입니다. GIMP C 구현을 이식하거나 설치한 기록으로 소개하지 않습니다. 아래 코드는 Photoshot의 공개 revision 19f45ed에서 발췌했습니다. 저장소는 소스를 공개하지만 고유 코드에 MIT·Apache·GPL 같은 오픈소스 이용 허가를 부여하지 않았다고 COPYRIGHT.md에 명시합니다.

레벨 계산은 기존 채널 조회표 컴파일에 들어갑니다. AI와 작성·수정한 연결 코드는 감마 수치를 손잡이 위치로 바꾸고 드래그 위치를 다시 수치로 되돌리는 부분, 이미지의 알파를 반영해 분포를 집계하는 부분입니다. 외부 문서의 개념 참고와 Photoshot 안에서 직접 구현한 코드를 구분했습니다.

RGB 공통→개별 채널의 레벨 계산 · src/adjustments.ts, L521–528 · 19f45ed
        if (t === 'levels')
          for (const c of ['rgb', channel])
            x =
              (v[c + 'OutBlack'] +
                (v[c + 'OutWhite'] - v[c + 'OutBlack']) *
                  clamp((x * 255 - v[c + 'Black']) / (v[c + 'White'] - v[c + 'Black'])) **
                    (1 / v[c + 'Gamma'])) /
              255;

5. 적용: 역함수와 분포 정의를 함께 고정하기

정규화 입력 t에 대해 출력은 t^(1/gamma)입니다. 출력 0.5가 되는 t는 0.5^gamma이므로 손잡이 위치는 black + (white − black) × 0.5^gamma입니다. 반대로 드래그 위치에서 t를 구하고 log(t) / log(0.5)로 감마를 얻습니다. 구현은 범위를 0.1~10으로 제한하고 표시 수치를 소수 둘째 자리로 반올림합니다.

분포 계산은 알파/255를 가중치로 사용합니다. 완전 투명 픽셀은 건너뛰고 반투명 픽셀은 비례해서 기여합니다. 가중 밝기 bin과 R·G·B bin을 따로 만들고, RGB 결합 분포는 세 채널 bin의 평균으로 만듭니다. 화면의 로그 높이 표시는 작은 봉우리를 보기 위한 것이며 조정 계산 자체를 바꾸지 않습니다.

출력 0.5에 대응하는 손잡이 위치 · src/levels-controls.ts, L2–5 · 19f45ed
// The middle handle marks the input that becomes 50% after inverse gamma.
export function gammaPosition(black: number, white: number, gamma: number) {
  return black + (white - black) * Math.pow(0.5, clamp(gamma, 0.1, 10));
}
드래그 위치에서 감마로 돌아오기 · src/levels-controls.ts, L6–13 · 19f45ed
export function gammaAtPosition(black: number, white: number, position: number) {
  const t = clamp(
    (position - black) / Math.max(1, white - black),
    Math.pow(0.5, 10),
    Math.pow(0.5, 0.1),
  );
  return Math.round(clamp(Math.log(t) / Math.log(0.5), 0.1, 10) * 100) / 100;
}
알파 가중치와 분포 집계 · src/tone-analysis.ts, L21–32 · 19f45ed
  for (let i = 0; i < data.length; i += 4) {
    const weight = data[i + 3] / 255;
    if (!weight) continue;
    bins[Math.round(0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2])] += weight;
    channelBins.r[data[i]] += weight;
    channelBins.g[data[i + 1]] += weight;
    channelBins.b[data[i + 2]] += weight;
    count += weight;
  }
  // RGB shows the distribution of channel values, not the luminance histogram.
  for (let i = 0; i < 256; i++)
    channelBins.rgb[i] = (channelBins.r[i] + channelBins.g[i] + channelBins.b[i]) / 3;

6. 실제 검증, 보존된 측정과 남은 한계

이번 고정 소스 테스트에서 감마 6개와 입력 범위 3개의 조합 18개가 위치→감마 왕복과 출력 0.5 조건을 통과했습니다. 알파 가중치, 밝기/RGB 구분, 빈 분포, 오른쪽 끝 bin 표시 검사도 통과했습니다. 입력 18·1.12·235, 출력 0·255로 회색 입력 9개를 계산한 결과는 과거 Photoshot 출력 9개와 모두 같았습니다.

실제 브라우저에서는 새 인물 사진 1536×1024를 열고 같은 레벨 값을 적용했습니다. 흰색점 235의 실행 취소는 255, 다시 실행은 235로 돌아왔습니다. 프로젝트를 저장하고 문서를 닫은 뒤 다시 열었을 때 두 레이어와 18·1.12·235 값이 복원됐습니다. 외부 연결 장애 때문에 이 실행은 로컬 배포 파일에서 수행했습니다. 공개 서비스 전체의 동작 완료로 넓혀 말하지 않습니다.

2026년 9월 25일의 보존된 Photoshop 차트 비교에는 평균 채널 절대 차이 0.229, 최대 1이 기록돼 있습니다. 이번에는 그 전체 차트나 Photoshop 출력을 새로 만들지 않았으므로 과거 측정으로만 표기합니다. 속도·색상 프로파일 전반·모든 이미지의 호환성을 이 작은 시험으로 보장하지 않습니다.

실제 로컬 배포본의 레벨 조정 화면. 입력 18·1.12·235, 출력 0·255. 인물 입력 사진은 과거 Photoshop 시험 차트와 다른 자산입니다.
Fig. 3 | 실제 로컬 배포본의 레벨 조정 화면. 입력 18·1.12·235, 출력 0·255. 인물 입력 사진은 과거 Photoshop 시험 차트와 다른 자산입니다.

Sources & reproducibility

  1. Photoshot — levels controls
  2. Photoshot — tone analysis
  3. Photoshot — levels test
  4. Photoshot — copyright notice
  5. GIMP — Levels manual
  6. Source replay data
  7. Archived levels comparison