영상은 결국 숫자 배열이다. 그 배열을 어떻게 다루느냐가 영상 처리의 전부다.
디지털 영상 기초
픽셀은 채널별 정수값이다. 8비트 RGB라면 각 채널이 0~255, 총 24비트로 한 픽셀을 표현한다. 단순해 보이지만 이 구조가 HW 설계 전반을 결정한다.
비트 심도가 올라가면 무슨 일이 생길까. 10비트 HDR은 채널당 1024단계를 표현한다. 사람 눈이 실제로 10비트 전부를 구분하는 건 아니다. 하지만 처리 과정에서 중간 계산이 반복될수록 8비트는 반올림 오차가 쌓인다. 그래서 내부 연산 정밀도는 높게 유지하고 최종 출력만 낮추는 것이 일반적이다.
메모리 측면에서 중요한 개념이 stride다.
폭이 640픽셀이어도 stride는 672일 수 있다. 행 끝에 패딩을 붙여 메모리 정렬을 맞추는 것이다. 이 패딩은 캐시 라인 정렬과 DMA 전송 효율을 위해 존재한다. 프레임버퍼가 디스플레이 컨트롤러로 넘어갈 때 정렬이 맞지 않으면 DMA가 쪼개서 여러 번 읽어야 한다. 소프트웨어에서 픽셀에 접근할 때 stride를 무시하면 행 단위로 어긋나는 이유도 여기에 있다.
이진 영상과 히스토그램
영상을 이진화하면 각 픽셀이 0 아니면 1이 된다. 어떤 값을 기준으로 나눌까. 오츄 알고리즘은 그 기준점을 자동으로 찾는다.
아이디어는 단순하다. 임계값 t로 픽셀을 두 그룹으로 나눴을 때 각 그룹 내 분산의 합이 최소가 되는 t를 고른다. 클래스 간 분산을 최대화하는 것과 수학적으로 동치다. 0~255를 전부 탐색해도 O(256)이니 사실상 상수 시간이다.
히스토그램이 여기서 핵심 자료구조다. 픽셀 전체를 한 번 훑어 256개 버킷에 카운트를 쌓으면 끝이다. 단일 패스, O(n). 이 구조가 하드웨어에 잘 맞는 이유가 있다. 버킷 256개는 SRAM에 올리기 충분하고, 픽셀을 스트림으로 흘리면서 누적만 하면 되니 파이프라인이 단순해진다. 랜덤 접근이 없고 순차적이다.
실제로 카메라의 AE(Auto Exposure) 측광 회로가 이 구조를 사용한다. 프레임 전체를 메모리에 올리기 전에 ISP 내부에서 히스토그램을 먼저 계산해 적정 노출값을 결정한다. 프레임이 시스템 메모리에 도착하기도 전에 측광이 끝나는 이유다.
모폴로지 연산(팽창·침식)은 이진 영상에서 작은 노이즈를 제거하거나 형태를 정리할 때 쓴다. 커널 크기만큼의 이웃 픽셀을 보고 OR/AND를 취하는 연산이다. 뒤에 나올 컨볼루션과 슬라이딩 윈도우 구조가 같다.
점 연산: 감마 보정
점 연산은 각 픽셀을 독립적으로 변환한다. 이웃 픽셀과 무관하다는 뜻이다.
감마 보정이 대표적이다. 출력 = 입력^γ. γ < 1이면 어두운 영역이 밝아지고, γ > 1이면 반대다. 왜 필요할까. 카메라 센서는 빛의 세기에 선형으로 반응하지만 사람의 눈은 어두운 영역 변화에 더 민감하다. 선형 데이터를 그대로 저장하면 어두운 영역에 비트가 낭비된다. 그래서 sRGB는 γ ≈ 2.2를 적용해 감마 인코딩을 한다. 저장할 때 어두운 영역에 더 많은 비트를 할당하는 셈이다.
HW 관점에서 흥미로운 지점이 있다. 지수 연산은 비싸다. 픽셀마다 pow(x, 2.2)를 호출하면 실시간 처리에서 버티기 어렵다. 그래서 LUT(Look-Up Table)로 대체한다. 0~255 입력값에 대한 출력을 미리 계산해 테이블에 올려두고, 실제 처리는 테이블 조회만 한다. O(1). 디스플레이 컨트롤러 내부에 이 감마 LUT가 하드웨어 회로로 구현되어 있다. CPU가 개입하지 않는다.
히스토그램 평활화는 히스토그램을 균등하게 펴서 명암 대비를 높이는 연산이다. 누적 히스토그램을 정규화한 값이 각 픽셀의 새 값이 된다. 이것도 결국 LUT 변환이다.
영역 연산: 컨볼루션
점 연산과 달리 영역 연산은 이웃 픽셀들을 함께 본다. 컨볼루션이 그 핵심이다.
작은 커널을 영상 위에서 슬라이딩하며 가중합을 계산한다. 커널 계수가 무엇이냐에 따라 블러, 샤프닝, 엣지 검출이 된다. 연산 자체는 동일하다.
각 출력 픽셀은 독립적으로 계산할 수 있다. 이것이 핵심이다. 출력 픽셀 A의 계산이 출력 픽셀 B에 영향을 주지 않는다. 따라서 수백만 픽셀을 동시에 계산할 수 있다. GPU가 컨볼루션에 강한 이유가 여기에 있다.
CPU는 코어가 수십 개지만 GPU는 수천 개의 작은 코어를 갖는다. 데이터 의존성이 없는 연산은 이 구조에 완벽하게 맞는다. 딥러닝의 Conv 레이어와 ISP의 노이즈 제거(NR) 필터가 연산 구조를 공유하는 이유도 그것이다.
SIMD(Single Instruction Multiple Data)도 같은 맥락이다. 8개의 픽셀에 같은 커널 가중치를 동시에 곱하는 것은 SIMD 명령어 하나로 처리된다. 커널 계수를 레지스터에 올려두고 픽셀 스트림을 흘리면 된다. 가우시안 커널처럼 분리 가능한(separable) 커널은 2D를 1D 두 번으로 쪼갤 수 있어 연산량이 더 줄어든다.
기하 연산
기하 변환은 픽셀의 위치를 바꾼다. 회전, 스케일, 어파인 변환. 수학적으로는 행렬곱으로 표현된다.
출력 좌표 (x', y') = M × (x, y, 1)ᵀ
M이 회전이면 회전 행렬, 스케일이면 대각 행렬이다. 어떤 변환이든 행렬 하나로 통일된다. 변환을 여러 개 연속으로 적용할 때도 행렬을 미리 합성하면 픽셀당 곱셈 한 번으로 끝난다.
행렬곱은 텐서 연산의 기본 단위다. 현대 GPU와 NPU에 텐서 연산 전용 유닛이 들어가는 배경이 여기에 있다. 기하 변환 가속은 GPU가 범용화되기 훨씬 전부터 그래픽 하드웨어의 핵심 기능이었다.
보간은 변환 후 정수가 아닌 좌표에 대한 픽셀값을 결정하는 문제다. 최근접 이웃(nearest neighbor)은 빠르지만 계단 현상이 생긴다. 쌍선형(bilinear)은 주변 4픽셀을 가중 평균해 부드럽게 만든다. 쌍삼차(bicubic)는 16픽셀을 본다. 정밀도와 연산량의 트레이드오프다.
실제 파이프라인에서 기하 변환이 쓰이는 대표적인 곳은 렌즈 왜곡 보정이다. 광각 렌즈는 직선을 휘게 만든다. 보정하려면 왜곡 계수를 미리 구해두고 프레임마다 역변환 행렬로 각 픽셀을 재매핑한다. EIS(Electronic Image Stabilization)도 구조가 같다. 자이로 데이터로 보정 행렬을 계산하고 기하 변환을 적용하는 것이다. 매 프레임마다 일어나는 일이다.