Rectangular부터 Savitzky-Golay까지 6가지 필터의 수학적 원리, 커널 호버 수식, RGB 채널 독립 처리, 8-bit / 16-bit 구현을 코드와 다이어그램으로 완전 정리합니다.
6개 필터는 모두 하나의 진입점 ApplySmoothing()을 통해 호출됩니다. 비트심도(8/16-bit)에 따라 파이프라인이 분기되고, 각 필터는 RGB 3채널을 독립 처리합니다.
// Apply8() / Apply16() 내 분기 if (doRect) → ApplyRect8() / ApplyRectPlanes16() if (doAvg) → ApplyBinomialAverage8() / ApplyBinomialAveragePlanes16() if (doMed && !doGaussMed) → ApplyWeightedMedian8(Binom) / ApplyWeightedMedianPlanes16() if (doGaussMed) → ApplyWeightedMedian8(Gauss) / ApplyWeightedMedianPlanes16() if (doGauss) → ApplyGaussian8() / ApplyGaussianPlanes16() if (isSg) → ApplySavitzkyGolaySeparable8() / ApplySavitzkyGolaySeparablePlanes16()
byte[] sBufferdouble[,] B,G,R모든 필터(SG 제외)는 1D 가중치 배열 하나만 생성하고, 루프 안에서 w = wY[wy+r] × wX[wx+r] 곱셈 한 번으로 2D 가중치를 즉시 계산합니다. 별도 2D 배열 할당 없음.
반경 r을 고정한 채 필터를 바꾸면 같은 (2r+1)×(2r+1) 크기 안에서 가중치 분포가 어떻게 달라지는지 확인하세요.
1/(2r+1)²로 균일합니다.
| 필터 | 가중치 방식 | 출력 | 엣지 보존 | 노이즈 강건성 | 8-bit Median 알고리즘 | 속도 |
|---|---|---|---|---|---|---|
| Rectangular | 균일 (모두 1/(2r+1)²) | 평균 | 낮음 | 낮음 | — | ★★★★★ |
| Binomial Avg | 이항 계수 C(n-1,k) | 가중 평균 | 보통 | 보통 | — | ★★★★☆ |
| Binom. Median | 이항 계수 | 가중 중앙값 | 높음 | 매우 높음 | Bucket[256] O(n+256) | ★★★☆☆ |
| Gauss. Median | exp(−x²/2σ²) | 가중 중앙값 | 높음 | 매우 높음 | Bucket[256] O(n+256) | ★★★☆☆ |
| Gaussian | exp(−x²/2σ²) | 가중 평균 | 보통 | 보통 | — | ★★★★☆ |
| Savitzky-Golay | 다항 회귀(QR) | 최소제곱 추정 | 매우 높음 | 보통 | — | ★★☆☆☆ |
컬러 이미지는 R·G·B 3채널의 조합입니다. 모든 필터는 세 채널을 완전히 독립적으로 필터링합니다. 비트심도에 따라 메모리 레이아웃·데이터 타입·Median 알고리즘이 달라집니다.
int p = ny*stride + nx*3b16 = buf[sp] | (buf[sp+1] << 8)// 8-bit: 인터리브 byte 배열에서 직접 채널 추출 int p = (ny * sStride) + (nx * 3); sumB += w * sBuffer[p]; // Blue offset +0 sumG += w * sBuffer[p + 1]; // Green offset +1 sumR += w * sBuffer[p + 2]; // Red offset +2 // 16-bit: ExtractPlanes()가 채널을 double[,]으로 사전 분리 planes.B[y, x] = (double)(sBuffer[sp] | (sBuffer[sp+1] << 8)); // LE 2-byte → double planes.G[y, x] = (double)(sBuffer[sp+2] | (sBuffer[sp+3] << 8)); planes.R[y, x] = (double)(sBuffer[sp+4] | (sBuffer[sp+5] << 8));
ClampToByte() → 24bppMedianThreadBuffers8ClampToUShort() → 48bppMedianPlaneBuf16Array.Clear(bucket, 0, 256); for (i) { bucket[vals[i]] += w[i]; total += w[i]; } double half = total / 2.0; for (i = 0; i < 256; i++) { acc += bucket[i]; if (acc > half + eps) return (byte)i; // ✓ if (acc >= half - eps) return 보간(i, nextNonEmpty); // (i+k+1)>>1 } // O(n+256) — 정렬 불필요!
// 값 배열 불변, 인덱스 배열만 정렬 for (i) idx[i] = i; comparer.SortValues = values; Array.Sort(idx, 0, count, comparer); // O(n log n) double half = total / 2.0, acc = 0; for (i) { acc += weights[idx[i]]; if (acc > half + eps) return values[idx[i]]; if (acc >= half - eps) return (lo + hi) / 2.0; } // 65536 버킷 없이 임의 정밀도 지원
커널 내 모든 픽셀에 동일한 가중치를 부여합니다. 가장 단순하고 빠르지만 엣지가 흐릿해집니다. Box Filter라고도 합니다.
| r | 크기 | 픽셀수 | 각 기여 |
|---|---|---|---|
| 1 | 3×3 | 9 | 1/9 ≈ 11.1% |
| 2 | 5×5 | 25 | 1/25 = 4% |
| 3 | 7×7 | 49 | 1/49 ≈ 2% |
| 4 | 9×9 | 81 | 1/81 ≈ 1.2% |
// 범용 경로 (GetIndex1D 기반 경계 처리) for (int wy=-r; wy<=r; wy++) { int ny = GetIndex1D(y+wy, height, mode); for (int wx=-r; wx<=r; wx++) { int nx = GetIndex1D(x+wx, width, mode); if (nx < 0 || ny < 0) { if (mode == ZeroPad) count++; continue; } int p = ny*sStride + nx*3; sumB += sBuffer[p]; sumG += sBuffer[p+1]; sumR += sBuffer[p+2]; count++; } } // 반올림 포함 정수 나눗셈 dBuffer[d] = (byte)((sumB + count/2) / count); dBuffer[d+1] = (byte)((sumG + count/2) / count); dBuffer[d+2] = (byte)((sumR + count/2) / count);
ClampToByte 불필요 (범위 자동 보장). 16-bit와 달리 별도 Interior 분기 없이 모든 픽셀에 GetIndex1D() 호출.// fullCount 사전 계산 (Interior 공통) int fullCount = (2*r+1) * (2*r+1); bool yIn = y>=r && y<h-r; if (yIn && x>=r && x<w-r) { // 내부: GetIndex1D() 호출 없음 outB[y,x] = sumB / fullCount; outG[y,x] = sumG / fullCount; outR[y,x] = sumR / fullCount; } else { // 경계: GetIndex1D() / Adaptive outB[y,x] = sumB / count; // 실제 count }
ClampToUShort()로 0~65535 보장Adaptive 모드에서는 이미지 밖으로 나가는 대신 실제 가용 영역으로 윈도우를 축소하고, count를 재계산합니다.
(sumB + count/2) / count8-bit Rectangular 평균은 정수 연산으로 수행됩니다. ClampToByte()가 불필요한 이유와 반올림 원리:
// 예시: r=1, count=9, 픽셀합=1026 // 정확한 평균: 1026 / 9 = 114.0 // C# 정수 나눗셈: 1026 / 9 = 114 (내림) // 반올림: (1026 + 4) / 9 = 1030 / 9 = 114 ✓ // 예시: 픽셀합=1031 // 정확한 평균: 1031 / 9 = 114.56 // 내림: 1031 / 9 = 114 // 반올림: (1031 + 4) / 9 = 1035 / 9 = 115 ✓ (0.5 이상 올림) dBuffer[d] = (byte)((sumB + count/2) / count); // ↑ long 타입 sumB: 최대 255×81 = 20655 → byte 자동 보장 // 0 ≤ 평균 ≤ 255이므로 ClampToByte() 불필요
double outB = sB / count 실수 나눗셈 후 ClampToUShort()로 범위 제한. 8-bit는 정수 연산만으로 범위가 자동 보장.Rectangular 평균에서 count(분모)는 경계 모드에 따라 다르게 계산됩니다. 이 차이가 경계 픽셀의 밝기에 직접 영향.
| Mode | 범위 밖 처리 | count 영향 | 결과 |
|---|---|---|---|
| Symmetric | 거울 반사 → 유효 인덱스 | (2r+1)² 고정 | 경계 데이터 반복 포함 |
| Replicate | 가장자리 복제 | (2r+1)² 고정 | 경계 픽셀 과다 반영 |
| ZeroPad | nx=-1 → 값 0, count++ | (2r+1)² 고정 | 경계 어두워짐 |
| ValidOnly | nx=-1 → skip | count < (2r+1)² | 유효 픽셀만 평균 |
| AdaptiveMask | skip (GetIndex1D 없음) | count < (2r+1)² | 유효 픽셀만 평균 |
| Adaptive | 윈도우 축소 | winW × winH | 축소된 영역만 평균 |
Rectangular 필터의 처리 과정을 4단계로 분해합니다. 모든 필터 중 가장 단순하지만, 경계 처리와 8/16-bit 분기를 이해하는 데 중요합니다.
8-bit: Ensure24bppFormat() → LockBits → byte[] sBuffer 추출. 16-bit: ExtractPlanes() → double[,] B/G/R 분리.
각 출력 픽셀에 대해 반경 r의 (2r+1)² 윈도우를 순회하며 R·G·B 채널별 독립 합산. 가중치 없이 단순 누적합.
Adaptive: 윈도우 축소 + count 재계산. Symmetric/Replicate: GetIndex1D()로 인덱스 매핑. ZeroPad: 범위 밖 = 0, count 유지. ValidOnly: 범위 밖 skip, count 감소.
8-bit: (sumB + count/2) / count 반올림 정수 나눗셈 → byte cast. 16-bit: sB / count 실수 나눗셈 → ClampToUShort().
16-bit 파이프라인은 Interior(경계에서 r 이상 떨어진) 픽셀에 대해 GetIndex1D() 호출을 완전히 생략하는 빠른 경로를 가집니다. 8-bit는 모든 픽셀에 GetIndex1D()를 호출합니다.
// ApplyRect8: Adaptive 분기만 별도, // 나머지는 모두 GetIndex1D() 경로 if (mode == BoundaryMode.Adaptive) { // 윈도우 축소 → 직접 루프 int left = Math.Min(r, x); int right = Math.Min(r, width-1-x); // ... } else { // 모든 픽셀에 GetIndex1D() 호출 for (wy) { int ny = GetIndex1D(y+wy, height, mode); for (wx) { int nx = GetIndex1D(x+wx, width, mode); // ← 내부 픽셀도 이 검사를 통과 } } }
int fullCount = (2*r+1) * (2*r+1); bool yInterior = y >= r && y < height-r; if (mode == Adaptive && !(yInterior && x >= r && x < width-r)) { // 경계 Adaptive: 윈도우 축소 } else if (yInterior && x >= r && x < width-r) { // ★ Interior 빠른 경로: // GetIndex1D() 호출 0회! double sB = 0; for (int wy = -r; wy <= r; wy++) for (int wx = -r; wx <= r; wx++) sB += planes.B[y+wy, x+wx]; outB[y,x] = sB / fullCount; // ↑ 사전 계산된 fullCount 재사용 } else { // 경계: GetIndex1D() 경로 }
// ApplyRect8 — Adaptive 분기 전체 코드 if (mode == BoundaryMode.Adaptive) { int left = Math.Min(r, x); // X 왼쪽 여유 int right = Math.Min(r, width - 1 - x); // X 오른쪽 여유 int top = Math.Min(r, y); // Y 위쪽 여유 int bottom = Math.Min(r, height - 1 - y); // Y 아래쪽 여유 int winW = left + right + 1; // 실제 윈도우 너비 int winH = top + bottom + 1; // 실제 윈도우 높이 int startX = x - left; // 윈도우 시작 X int startY = y - top; // 윈도우 시작 Y long sumB = 0, sumG = 0, sumR = 0; int count = winW * winH; // 축소된 count for (int yy = 0; yy < winH; yy++) { int rowOffset = (startY + yy) * sStride; for (int xx = 0; xx < winW; xx++) { int p = rowOffset + (startX + xx) * 3; sumB += sBuffer[p]; // Blue sumG += sBuffer[p + 1]; // Green sumR += sBuffer[p + 2]; // Red } } int d = y * dStride + x * 3; dBuffer[d] = (byte)((sumB + (count / 2)) / count); dBuffer[d + 1] = (byte)((sumG + (count / 2)) / count); dBuffer[d + 2] = (byte)((sumR + (count / 2)) / count); }
파스칼의 삼각형(이항 계수)으로 가중치를 생성합니다. 중앙 픽셀에 높은 가중치를 주어 가우시안과 유사하지만, 정수 기반으로 더 빠르고 Dictionary 캐싱이 가능합니다.
| n (r) | 계수 | 합 |
|---|---|---|
| 3 (r=1) | [1, 2, 1] | 4 = 2² |
| 5 (r=2) | [1,4,6,4,1] | 16 = 2⁴ |
| 7 (r=3) | [1,6,15,20,15,6,1] | 64 = 2⁶ |
| 9 (r=4) | [1,8,28,56,70,...] | 256 = 2⁸ |
// GetBinomial1D(n) — 캐싱 후 반환 var c = new double[n]; c[0] = 1.0; for (int i = 1; i < n; i++) c[i] = c[i-1] * (n-i) / i; // 2D 적용: w = coeff1D[wy+r] × coeff1D[wx+r] ← 외적 즉시 계산
double[] coeff = GetBinomial1D(windowSize); for (int wy=-r; wy<=r; wy++) { double wyW = coeff[wy+r]; // 행 가중치 for (int wx=-r; wx<=r; wx++) { double w = wyW * coeff[wx+r]; // 2D = 행×열 sumB += w * sBuffer[p]; sumG += w * sBuffer[p+1]; sumR += w * sBuffer[p+2]; denom += w; } } out = ClampToByte(sum / denom);
// fullDenom: Interior용 분모 1회 사전 계산 double fullDenom = 0; for (wy) for (wx) fullDenom += coeff[wy+r] * coeff[wx+r]; if (yInterior && xInterior) { outB[y,x] = sB / fullDenom; // ← 최고속 outG[y,x] = sG / fullDenom; outR[y,x] = sR / fullDenom; } else { // 경계: GetBinomial1D(winW/H) 재생성 }
이항 가중 평균의 분모는 경계 모드에 따라 다르게 계산됩니다. 이 차이가 경계 픽셀의 밝기에 직접 영향.
| 경로 | denom 계산 | 코드 |
|---|---|---|
| Interior (16-bit) |
fullDenom (1회 사전계산) | for(wy)for(wx) fullDenom += coeff[wy+r]*coeff[wx+r] |
| Adaptive | sumWx × sumWy (축소된 계수 합의 곱) |
cx = GetBinomial1D(winW);denom = sumWx * sumWy |
| AdaptiveMask | 유효 픽셀 가중치만 누적 | if ((uint)ny >= height) continue;denom += w; |
| ZeroPad | 전체 가중치 누적 (범위 밖도 denom에 포함) |
if (nx<0) { denom+=w; continue; } |
| Symmetric/Replicate | 전체 가중치 누적 | 모든 nx 유효 → denom += w |
GetBinomial1D(winW)로 새 계수 생성. AdaptiveMask는 원래 윈도우 유지하되 범위 밖 스킵 → denom이 자동 감소하여 재정규화.// ApplyBinomialAverage8 — AdaptiveMask 분기 else if (mode == BoundaryMode.AdaptiveMask) { double sumB = 0, sumG = 0, sumR = 0; double denom = 0; for (int wy = -r; wy <= r; wy++) { int ny = y + wy; if ((uint)ny >= (uint)height) continue; // ↑ unsigned 비교로 음수&초과를 한 번에 검사 // (uint)(-1) = 4294967295 >= height → skip double wyW = coeff1D[wy + r]; for (int wx = -r; wx <= r; wx++) { int nx = x + wx; if ((uint)nx >= (uint)width) continue; double w = wyW * coeff1D[wx + r]; int p = (ny * sStride) + (nx * 3); sumB += w * sBuffer[p]; sumG += w * sBuffer[p + 1]; sumR += w * sBuffer[p + 2]; denom += w; // 유효 픽셀만 반영 } } if (denom <= 0) denom = 1; dBuffer[d] = ClampToByte(sumB / denom); dBuffer[d+1] = ClampToByte(sumG / denom); dBuffer[d+2] = ClampToByte(sumR / denom); }
(uint)ny >= (uint)height는 음수와 범위 초과를 한 번의 비교로 처리하는 최적화. unsigned 변환 시 음수는 매우 큰 수가 되어 자동으로 >= height. GetIndex1D() 호출보다 빠름.Adaptive 모드에서 경계 픽셀은 축소된 윈도우에 맞는 새로운 Binomial 계수를 생성합니다. GetBinomial1D(winW/H) 캐싱으로 동일 크기 재계산 없음.
// Adaptive 분기 핵심 int left = Math.Min(r, x); int right = Math.Min(r, width - 1 - x); int winW = left + right + 1; // 축소된 너비 var cx = GetBinomial1D(winW); // 새 Binomial 계수 var cy = GetBinomial1D(winH); double sumWx = 0, sumWy = 0; for (i) sumWx += cx[i]; // 축소 계수 합 for (i) sumWy += cy[i]; double denom = sumWx * sumWy; // 2D 외적 합 // 예: 모서리(x=0,y=0) r=2 // winW=1+2+1=3 → cx=[1,2,1] 합=4 // winH=1+2+1=3 → cy=[1,2,1] 합=4 // denom = 4×4 = 16 (원본 5×5: 16²=256)
이항 계수를 가중치로 사용해 가중 중앙값을 구합니다. 평균과 달리 극단값(노이즈)에 강건하고 엣지를 보존합니다.
파스칼의 삼각형 재귀로 이항 계수를 생성합니다. Dictionary<int, double[]> 캐싱으로 동일 windowSize 재호출 시 O(1) 반환. Gaussian의 매번 exp() 계산 대비 구조적 속도 우위입니다.
// SmoothingConductor.cs — GetBinomial1D(n) private static readonly Dictionary<int, double[]> _binom1D = new Dictionary<int, double[]>(); private static readonly object _binom1DLock = new object(); private static double[] GetBinomial1D(int n) { lock (_binom1DLock) { if (_binom1D.TryGetValue(n, out var cached)) return cached; // 캐시 적중 → 즉시 반환 var c = new double[n]; c[0] = 1.0; for (int i = 1; i < n; i++) c[i] = c[i - 1] * (n - i) / i; // ↑ 파스칼 삼각형 재귀: C(n-1,i) // 정규화 없이 원시 정수 계수 생성 _binom1D[n] = c; return c; } }
| n (r) | 계수 | 합 |
|---|---|---|
| 3 (r=1) | [1, 2, 1] | 4 = 2² |
| 5 (r=2) | [1, 4, 6, 4, 1] | 16 = 2⁴ |
| 7 (r=3) | [1, 6, 15, 20, 15, 6, 1] | 64 = 2⁶ |
| 9 (r=4) | [1, 8, 28, 56, 70, ...] | 256 = 2⁸ |
w = w1D[wy+r] × w1D[wx+r]2D 커널을 명시적 배열로 생성하지 않고, 루프 안에서 행 가중치 × 열 가중치를 곱해 즉시 계산합니다. Median 전용 코드에서는 픽셀값(vals[])과 가중치(w[])를 함께 수집합니다.
// ApplyWeightedMedian8() — 범용 경로 (Symmetric/Replicate/ZeroPad 등) double[] w1D = GetBinomial1D(windowSize); int maxSamples = checked(windowSize * windowSize); for (int wy = -r; wy <= r; wy++) { int ny = GetIndex1D(y + wy, height, mode); for (int wx = -r; wx <= r; wx++) { int nx = GetIndex1D(x + wx, width, mode); double wt = w1D[wy + r] * w1D[wx + r]; // ↑ 2D 가중치 = 행w × 열w (외적) if (nx < 0 || ny < 0) { if (mode == BoundaryMode.ZeroPad) { // 값 0, 가중치 유지 valsB[count] = 0; valsG[count] = 0; valsR[count] = 0; w[count] = wt; count++; } continue; } int p = (ny * sStride) + (nx * 3); valsB[count] = sBuffer[p]; // Blue valsG[count] = sBuffer[p + 1]; // Green valsR[count] = sBuffer[p + 2]; // Red w[count] = wt; // 가중치 count++; } } // 각 채널 독립적으로 가중 중앙값 계산 byte b = WeightedMedianDouble8(valsB, w, bucket, count); byte g = WeightedMedianDouble8(valsG, w, bucket, count); byte rr = WeightedMedianDouble8(valsR, w, bucket, count); int d = (y * dStride) + (x * 3); dBuffer[d] = b; dBuffer[d+1] = g; dBuffer[d+2] = rr;
_binom1D)sumB += w × pixel → 가중합 누적만 (가중치 별도 저장 불필요)일반 중앙값은 값을 정렬해야 합니다(O(n log n)). 8-bit 이미지의 경우 픽셀값이 0~255 범위이므로, 크기 256짜리 버킷 배열에 가중치를 누적한 뒤 누적합이 전체의 절반을 넘는 지점을 찾는 것으로 O(n + 256) = O(n)에 해결합니다.
반경 r의 (2r+1)² 픽셀을 순회하며 pixel 값(byte)과 2D Binomial 가중치(double)를 ThreadLocal 버퍼에 적재. R·G·B 채널별 독립 배열.
bucket[value] += weight
크기 256 배열 초기화 후, 각 픽셀의 강도(0~255)를 인덱스로 하여 가중치를 누적합니다. B/G/R 채널마다 독립적으로 3회 수행.
acc > total / 2
버킷 0→255 순서로 누적합을 계산하여 전체 가중치의 절반(+ε)을 초과하는 첫 인덱스를 반환. ε = total × 10⁻¹² (부동소수점 허용 오차).
누적합이 정확히 절반과 ε 이내이면, 다음 비어있지 않은 버킷과 보간하여 부드러운 중앙값 반환: (i + k + 1) >> 1
// SmoothingConductor.cs — WeightedMedianDouble8() 전체 코드 private static byte WeightedMedianDouble8( byte[] values, double[] weights, double[] bucket, int count) { if (count <= 0) return 0; Array.Clear(bucket, 0, 256); // ① 버킷 초기화 double total = 0; for (int i = 0; i < count; i++) { double w = weights[i]; bucket[values[i]] += w; // ② 픽셀값 → 버킷에 가중치 누적 total += w; } if (total <= 0) return 0; double half = total / 2.0; double eps = total * 1e-12; // 부동소수점 허용 오차 double acc = 0; for (int i = 0; i < 256; i++) // ③ 0→255 순서 탐색 { double w = bucket[i]; if (w > 0) { acc += w; if (acc > half + eps) // 중앙값 발견! return (byte)i; if (acc >= half - eps) // ④ TieBreak 보간 { for (int k = i + 1; k < 256; k++) { if (bucket[k] > 0) return (byte)((i + k + 1) >> 1); } return (byte)i; } } } return 255; }
Parallel.For 행 단위 병렬화에서 각 스레드가 독립 버퍼를 사용합니다. lock 없이 완전 병렬 처리.
// 스레드별 버퍼 구조체 private sealed class MedianThreadBuffers8 { public readonly byte[] ValsB; // Blue 픽셀값 public readonly byte[] ValsG; // Green 픽셀값 public readonly byte[] ValsR; // Red 픽셀값 public readonly double[] W; // 2D 가중치 public readonly double[] Bucket; // [256] 히스토그램 public MedianThreadBuffers8(int maxSamples) { ValsB = new byte[maxSamples]; ValsG = new byte[maxSamples]; ValsR = new byte[maxSamples]; W = new double[maxSamples]; Bucket = new double[256]; } }
// ThreadLocal 생성 & 사용 int maxSamples = checked(windowSize * windowSize); // ↑ r=2 → 5×5=25, r=5 → 11×11=121 using (var threadBuffers = new ThreadLocal<MedianThreadBuffers8>( () => new MedianThreadBuffers8(maxSamples))) { Parallel.For(0, height, y => { var buf = threadBuffers.Value; // 스레드별 독립 버퍼 → 동기화 없음 var valsB = buf.ValsB; var valsG = buf.ValsG; var valsR = buf.ValsR; var w = buf.W; var bucket = buf.Bucket; // bucket[256]은 WeightedMedianDouble8 // 내부에서 Array.Clear 후 재사용 // → GC 없음, 할당 없음 for (int x = 0; x < width; x++) { // ... 2D 수집 + 중앙값 계산 ... } proxy?.StepRows(1); }); }
// 가중치 생성 (Binomial) double[] w1D = GetBinomial1D(windowSize); // 2D 수집: vals[] + weights[] for (wy) for (wx) { double w = w1D[wy+r] * w1D[wx+r]; valsB[k] = sBuffer[p]; weightsB[k] = w; } // Bucket 알고리즘 O(n+256) bucket[vals[i]] += weights[i]; total += w; // Find: 누적 >= total/2 인 첫 i → 중앙값 if (acc > half + eps) return (byte)i;
// 동일 이항 가중치, 단 정렬 기반 double[] w1D = GetBinomial1D(windowSize); for (i) idx[i] = i; comparer.SortValues = values; Array.Sort(idx, 0, count, comparer); // O(n log n) for (i) { acc += weights[idx[i]]; if (acc > half + eps) return values[idx[i]]; if (acc >= half - eps) return (lo+hi)/2.0; }
가중 중앙값 탐색에서 누적 가중치가 정확히 total/2에 도달하면 두 인접 값 사이에 중앙값이 위치합니다. 이 경계 상황을 TieBreak라 하며, 8-bit와 16-bit에서 보간 방식이 다릅니다.
코드는 부동소수점 비교를 위해 eps = total × 10⁻¹²의 미세 허용 밴드를 설정합니다. 누적값(acc)이 이 밴드 안에 들어오면 TieBreak 보간을 수행합니다.
// acc가 [half−eps, half+eps] 구간에 진입 if (acc >= half - eps) { // 다음 비어있지 않은 버킷 k 탐색 for (int k = i + 1; k < 256; k++) { if (bucket[k] > 0) return (byte)((i + k + 1) >> 1); } return (byte)i; // 뒤에 값 없으면 현재 반환 }
(i + k + 1) >> 1 = ⌊(i + k + 1) / 2⌋| i (현재) | k (다음) | (i+k+1)>>1 | 설명 |
|---|---|---|---|
| 100 | 101 | 101 | 연속 → (201+1)/2=101 정확 |
| 100 | 102 | 101 | 1칸 갭 → (203)/2=101.5 → ⌊⌋=101 |
| 100 | 104 | 102 | 큰 갭 → (205)/2=102.5 → ⌊⌋=102 |
| 200 | 201 | 201 | 연속 → (402)/2=201 정확 |
+1과 비트 시프트>>1로 반올림을 구현하여 나눗셈 없이 처리.// acc가 [half−eps, half+eps] 구간에 진입 if (acc >= half - eps && i + 1 < count) { double lo = values[idx[i]]; double hi = values[idx[i + 1]]; return (lo + hi) / 2.0; }
idx[i]와 idx[i+1]은 값 기준 인접 원소이므로 빈 버킷 탐색이 불필요합니다.
| lo | hi | (lo+hi)/2 | 설명 |
|---|---|---|---|
| 30000.0 | 30002.0 | 30001.0 | 정확한 중간값 |
| 30000.0 | 30001.0 | 30000.5 | 소수점 정밀도 보존 |
(i+k+1)>>1 반올림 정수 보간. byte 출력이므로 정수 결과만 가능.(lo+hi)/2.0 연속 보간. double 내부 연산 → ClampToUShort() 시 반올림 출력.
Binomial Weighted Median은 엣지와 원본 구조를 보존하면서 임펄스·랜덤 노이즈만 선택적으로 제거하는 것이 핵심 강점이며, 이 특성은 다양한 산업 및 연구 분야에서 실질적인 이점을 제공합니다.
균일 중앙값(Rectangular Median)에서 발생하는 Cross-Hatching(격자) Artifact를 Binomial Weighted Median이 어떻게 구조적으로 개선하는지 분석합니다.
C(n-1,k) ∝ Gaussian 근사)하므로,
대각 모서리(거리=√2·r)의 기여가 축 방향(거리=r)보다 자연스럽게 작아져 등방향(isotropic) 응답에 근접합니다.
이는 원형 커널(computational cost가 높음)을 사용하지 않고도 방향성 아티팩트를 억제하는 구조적 해법입니다.
노이즈가 강할 때 r을 키워 더 넓은 영역을 참조하면, 대부분의 필터는 원본 구조도 함께 뭉개집니다. Binomial Weighted Median은 r을 키워도 원본 신호를 유지하는 독특한 강건성을 가집니다.
| r | 커널 | 중앙 2D% | 중앙/모서리 비 | 중앙+인접4 누적% | 원본 영향력 |
|---|
1 − max(wᵢ)에 비례합니다.
Binomial의 2D 최대 가중치 = (C(2r,r)/2^(2r))²는 Stirling 근사에 의해 ≈ 1/(π·r)로 감쇠합니다.| 사용 사례 | 핵심 요구사항 | Binomial WM이 충족하는 근거 |
|---|---|---|
| OCR | 획 엣지 보존 + 노이즈 제거 | Bimodal 분포에서 다수파 선택 → 엣지 shift=0, 이진화 호환 |
| 2D 이미지 보정 | 핫 픽셀 제거 + 방향 아티팩트 억제 | Bell-shape 등방향 감쇠 → Cross-Hatching 억제, 모든 r에서 임펄스 제거 보장 |
| 레이저 빔 프로파일링 | FlatTop edge 보존 + 센서 노이즈 제거 | Median의 edge shift=0 특성 + 미세 ripple 보존(중앙 집중 가중치) |
| ML 전처리 | 특징 보존 + 배치 일관성 | r만 파라미터 → 대규모 배치 일관 적용, 엣지 보존 → gradient 신호 유지 |
Binomial Median과 동일한 알고리즘에 가우시안 가중치를 사용합니다. sigmaFactor로 σ를 정밀 조절해 감쇠 강도를 제어합니다.
// ComputeGaussian1D(len, sigma) — sigma = windowSize / sigmaFactor double twoSigmaSq = 2 * sigma * sigma; for (int i = 0; i < len; i++) { int x = i - (len-1)/2; g[i] = Math.Exp(-(x*x) / twoSigmaSq); // 가우시안 커브 sum += g[i]; } for (i) g[i] /= sum; // 합=1 정규화
// useGaussianWeights 플래그 하나로 가중치 전략 전환 double[] w1D = useGaussianWeights ? ComputeGaussian1D(windowSize, windowSize / sigmaFactor) // Gaussian : GetBinomial1D(windowSize); // Binomial // ↑ 이후 2D 외적, 수집, Bucket/Sort 로직 완전 동일
// Gaussian 1D 가중치 생성 (매번 exp 계산) double[] w1D = ComputeGaussian1D(windowSize, windowSize / sigmaFactor); // 이후 Bucket 알고리즘 동일 for (wy) for (wx) { double w = w1D[wy+r] * w1D[wx+r]; valsB[k] = sBuffer[p]; weightsB[k] = w; } // WeightedMedianDouble8() → Bucket[256] // R·G·B 채널 독립 반복
// Gaussian 가중치 + 16-bit Sort 조합 double[] w1D = ComputeGaussian1D(windowSize, sigma); // planes.B/G/R[y,x]에서 수집 후 // 동일 WeightedMedianSorted16 호출 for (i) idx[i] = i; Array.Sort(idx, 0, count, comparer); // 누적 가중치 ≥ total/2 탐색
Gaussian Weighted Median은 Binomial Median과 완전히 동일한 파이프라인을 가집니다. 유일한 차이는 Step 1의 가중치 생성 함수입니다.
σ = windowSize / sigmaFactor로 σ를 계산한 뒤 exp(−x²/2σ²) 생성. 합=1 정규화. Binomial의 Dictionary 캐싱 없음 — 매번 exp() 호출.
w = g1D[wy+r] × g1D[wx+r]로 2D 가중치 생성. 픽셀값(byte/double)과 가중치를 ThreadLocal 버퍼에 적재. R·G·B 채널별 독립.
8-bit: bucket[value] += weight → 누적합 50% 탐색 O(n+256). 16-bit: Array.Sort(idx) → 누적합 탐색 O(n log n). Binomial Median과 완전 동일한 알고리즘.
8-bit: (i+k+1)>>1 정수 보간. 16-bit: (lo+hi)/2.0 실수 보간. 동일 TieBreak 로직.
sigmaFactor(σF)는 가우시안 커널의 유효 범위를 결정합니다. σF가 클수록 중앙에 집중, σF가 작을수록 넓게 분산됩니다. 이것이 중앙값 결과에 미치는 영향:
| σF | σ (r=2) | 중앙 가중치 | 모서리 가중치 | 유효 범위 | 특성 |
|---|---|---|---|---|---|
| 3 (넓음) | 1.67 | ~0.12 | ~0.06 | ~3σ=5.0 > r | 모든 픽셀 비중 유사 → Rectangular Median과 유사 |
| 6 (기본) | 0.83 | ~0.23 | ~0.001 | ~3σ=2.5 ≈ r | 중앙 집중 + 먼 픽셀 무시 → 균형 잡힌 스무딩 |
| 12 (좁음) | 0.42 | ~0.48 | ~10⁻⁶ | ~3σ=1.3 < r | 중앙 거의 단독 → 원본에 가까움 |
모든 픽셀의 가중치가 비슷 → 단순 중앙값(Rectangular Median)과 유사. 노이즈 제거 강하지만 디테일도 소실. 넓은 영역의 소금-후추 노이즈 제거에 효과적.
중앙 픽셀이 거의 단독으로 중앙값을 결정 → 원본 이미지에 가까움. 스무딩이 거의 없지만, 바로 인접한 극단 노이즈만 선택적으로 제거.
Gaussian Median의 Adaptive 경계는 축소된 윈도우에 맞는 새 가우시안 커널을 재생성합니다. σ도 비례 축소됩니다.
// ApplyWeightedMedian8 — Adaptive + Gaussian int left = Math.Min(r, x); int right = Math.Min(r, width - 1 - x); int winW = left + right + 1; int winH = top + bottom + 1; // σ를 축소된 윈도우에 비례하여 재계산 double sigmaLocalX = winW / sigmaFactor; double sigmaLocalY = winH / sigmaFactor; // 새 가우시안 커널 생성 (캐싱 없음!) var gx = ComputeGaussian1D(winW, sigmaLocalX); var gy = ComputeGaussian1D(winH, sigmaLocalY); // 2D 수집 (축소된 윈도우 내) for (yy = 0; yy < winH; yy++) { for (xx = 0; xx < winW; xx++) { double w = gy[yy] * gx[xx]; // 축소 커널 외적 valsB[k] = sBuffer[p]; weights[k] = w; k++; } } // 동일 WeightedMedianDouble8() 호출
두 필터는 동일한 Weighted Median 알고리즘을 공유하되, 가중치 생성 방식이 다릅니다. 이 한 가지 차이가 이미지 결과물과 사용 시나리오에서 구체적으로 어떤 차이를 만드는지 깊이 분석합니다.
두 필터의 가중치를 같은 r에서 시각적으로 비교합니다. 슬라이더로 반경을 조절해 어떻게 달라지는지 확인하세요.
실제 픽셀값 예시로 두 필터가 어떻게 다른 결과를 내는지 계산합니다. (픽셀값 0~255 기준)
GetBinomial1D()는 Dictionary 캐싱으로 동일 windowSize에서 재계산 없이 O(1) 반환합니다. 반면 ComputeGaussian1D()는 매번 exp()를 n번 호출해야 합니다.
// Binomial: 캐싱 적중 시 O(1) _binom1D.TryGetValue(n, out var c) // ← 즉시 반환 // Gaussian: 항상 O(n) exp() 연산 g[i] = Math.Exp(-(x*x) / twoSigmaSq) // 매번
r 하나만 결정하면 됩니다. Gaussian은 최적 σ를 찾아야 하지만, Binomial은 파스칼 삼각형의 수학적 특성이 자동으로 "합리적인" 가중치를 보장합니다. 사용자 오조작이 없습니다.
Weighted Median 출력 방식에서는 가중치의 상대적 비율이 중앙값 탐색에 영향을 줍니다. r≤3에서 이항 계수의 중앙 집중도는 가우시안(σF=6)과 오차 <2% 수준으로 거의 동일합니다.
이항 계수의 2D 외적 합은 2^(4r)로, 비트 시프트 정규화가 가능합니다. 고성능 환경에서 나눗셈 대신 >> 4r 연산으로 대체할 수 있습니다.
// r=2: 합=16² = 256 = 2^8 normalized = raw >> 8; // /256 대신 // r=3: 합=64² = 4096 = 2^12 normalized = raw >> 12; // /4096 대신
Weighted Median의 핵심 조건은 단 하나입니다: 중앙 픽셀의 누적 가중치가 50% 미만이어야 주변 픽셀들이 노이즈를 억제합니다. 50% 이상이면 가중 중앙값 탐색이 노이즈 픽셀에서 멈춰 노이즈가 그대로 출력됩니다.
| r | 커널 | Binomial 2D 중앙% |
노이즈 제거 | Gaussian 2D 중앙% (σF=6) |
노이즈 제거 | 우위 |
|---|
(C(n−1,r) / 2^(n−1))² — r=1부터 25%로 시작해 r이 커질수록 계속 낮아집니다. 항상 50% 미만이므로 모든 r에서 노이즈 제거 보장됩니다.g(0)² — σ = n/σF가 매우 작을 때 exp(0)²=1이 전체 합을 압도해 r=1에서 61.9%까지 치솟습니다.
r=1에서만 50%를 초과해 노이즈가 통과되며, r=2부터는 23.0%로 정상 동작합니다. 즉 sigmaFactor=6 고정 시, r=1 소형 커널에서만 Gaussian Median이 Salt-Pepper 노이즈를 원본 그대로 통과시킵니다.
핵심 명제: "σ가 일치된 조건에서, weighted median 연산에 한정하여, 모든 실용적 r(1~15)에서 Binomial WM이 미세하게 우위이다." — 이는 수학적으로 성립하는 구조적 사실입니다.
σ(2차 모멘트)를 일치시켜도 4차 모멘트(첨도, kurtosis)는 여전히 다릅니다. Binomial B(2r, 0.5)의 첨도 κ = 3 − 1/r은 Gaussian의 κ=3보다 항상 작습니다. 이는 CLT 수렴이 완료되지 않은 유한 r의 구조적 성질이며 반례가 없습니다.
| r | Binomial κ | Gaussian κ | Binom 2D n_eff | Gauss 2D n_eff | Binomial 우위 |
|---|
WeightedMedianDouble8() 함수를 공유합니다. 차이는 입력 가중치의 수학적 성질입니다: Binomial 가중치는 정수값 double(1.0, 4.0, 6.0 등 — IEEE 754에서 정확히 표현됨)이고, Gaussian 가중치는 exp() 결과(무리수의 근사값)입니다.double half = total / 2.0; // 이항 계수=정수값 → 정확 double eps = total * 1e-12; // 범용 코드 공유 (Binomial엔 불필요) if (acc > half + eps) return i; // 정수값끼리 비교 → 결과 정확
double half = total / 2.0; // 누적 오차 존재 double eps = total * 1e-12; // 보정값 도입 필요 if (acc > half + eps) return i; // 근사 비교
Weighted Median은 가중 투표 시스템입니다. 노이즈가 중앙에 위치할 때 주변 signal 픽셀들이 outvote하려면 중앙 가중치가 낮을수록 유리합니다. Platykurtic인 Binomial은 σ를 맞춰도 항상 중앙 가중치가 낮습니다.
| r | match σ | Binom 중앙2D | Gauss 중앙2D | Binom Signal 우위비 | Gauss Signal 우위비 | 저항력 차이 |
|---|
| 우위 근거 | 반례 가능성 | 이유 |
|---|---|---|
| Platykurtic → n_eff ↑ | 없음 | κ = 3−1/r < 3 은 모든 유한 r에서 수학적으로 항상 성립 |
| 정수값 double 정밀성 | 없음 | 이항 계수는 정수값 double로 누적 시 오차 없음, exp() 근사 오차와 구조적 차이 |
| 유한 지지 (절단 없음) | 없음 | Binomial의 정의역 = 윈도우, 이는 분포의 구조적 성질 |
| 중앙 noise 저항력 ↑ | 없음 | 중앙 비중 < Gaussian은 같은 σ에서 모든 유한 r에서 항상 성립 |
| 상황 | Binomial Median 추천 | Gaussian Median 추천 |
|---|---|---|
| 소형 커널 r=1 (기본 σF=6) |
✓ 명백히 우위 2D 중앙 25% → 노이즈 제거 보장 |
✗ 필터 무효화 2D 중앙 61.9% > 50% → 중앙 노이즈 통과, σF 재조율 필수 |
| 소형 커널 r=2 (기본 σF=6) |
✓ 미세 우위 2D 중앙 14.1% → 노이즈 제거, 구조적 n_eff 우위 |
△ 정상 동작 2D 중앙 23.0% < 50% → 노이즈 제거됨. Binomial보다 중앙 집중도 약간 높으나 실용적으로 허용 범위 |
| 소형 커널 r=1 (σF를 2 이하로 낮춘 경우) |
✓ 파라미터 없이 동등 수준 | σF≤2로 튜닝 시 정상 동작 (2D 중앙 <50%), 단 추가 설정 필요 |
| Salt-Pepper 노이즈 제거 (r=2 이상) |
✓ 모든 r에서 안정적 제거 | r≥2부터 σF=6에서도 정상 동작, 그러나 Binomial이 파라미터 없이 더 간단 |
| 노이즈 강도가 다양한 이미지들의 배치 처리 | ✓ 동일 r 재호출 시 캐시 효과 | exp() 기반 — 캐싱 효율이 Binomial보다 낮음 |
| 텍스처가 복잡한 이미지 정밀 처리 | r만 조절 가능, 유연성 낮음 | ✓ σ로 세밀 조율 가능 (단 r=1에서 σF > 2 사용 시 필터 무효화 주의) |
| r이 큰 경우 (r≥4) 강한 스무딩 | ✓ 전체 윈도우 고르게 활용, 캐싱 효과 극대화 | σF=6 고정 시 r↑ → σ=n/6 비례 증가 → 중앙 집중도 감소 (r=4: 2D 중앙 7.1%). r≥2부터 모두 정상 동작하나 Binomial(r=4: 2D 중앙 1.5%)보다 중앙 집중도 여전히 높음 |
| 가우시안 노이즈 모델 최적화 (Weighted Mean 한정) |
Weighted Median에서는 Gaussian과 동등 — 커널 형태가 L₁ 최적성에 무관 | ※ 주의 Gaussian Weighted Mean이 Gaussian 노이즈에 L₂ MLE이나, Weighted Median에서는 이 최적성 미성립. Mean/Median 혼동 주의 |
| 파라미터 없이 빠른 적용 필요 시 | ✓ r만 설정, 즉시 사용 — 모든 r에서 안정 | σF 기본값(6)은 r=1에서 필터 무효화. r에 맞게 반드시 σF≤2(r=1), σF≤4(r=2) 조율 |
| Adaptive 경계 모드 + 반복 스무딩 | ✓ 경계마다 캐시에서 조회 | 경계마다 exp() 재계산, 캐시 히트율 낮음 |
| 결론 |
r=1 구간: Binomial 명백한 우위
σF=6 기준 Gaussian의 2D 중앙 가중치 61.9% > 50%로 필터 무효화. Binomial은 파라미터 없이 모든 r에서 안정적으로 노이즈 제거.
r≥2: 성능 수렴, Binomial 미세 구조적 우위 유지
r≥2부터 σF=6에서도 Gaussian이 정상 동작. 단 Binomial은 Platykurtic 특성(n_eff 우위), 정수 산술, 파라미터 불필요 등 실용적 우위 유지.
|
|
// r=2: [1,4,6,4,1] / 16 // 중앙 기여: 6/16 = 37.5% // 최외곽: 1/16 = 6.25% // 비율: 6× 차이
// r=2, σF=6: σ=5/6≈0.83 // 중앙 기여(1D): ≈47.9% (σF=6 기준) // 최외곽(1D): ≈2.7% (σF=6 기준) // σF 변화로 비율 연속 조절 가능
가우시안 가중치로 가중 평균을 계산합니다. Median이 아닌 평균이라 연속적이고 부드러운 결과를 냅니다. 이미지 처리에서 가장 널리 쓰이는 블러 방식입니다.
double[] g1D = ComputeGaussian1D(windowSize, sigma); double sumB=0,sumG=0,sumR=0,denom=0; for (wy) { double wyW = g1D[wy+r]; for (wx) { double w = wyW * g1D[wx+r]; sumB += w * sBuffer[p]; sumG += w * sBuffer[p+1]; sumR += w * sBuffer[p+2]; denom += w; } } outB = ClampToByte(sumB / denom); outG = ClampToByte(sumG / denom); outR = ClampToByte(sumR / denom);
// fullDenom 1회 사전 계산 (Interior 공통) double fullDenom = 0; for (wy) for (wx) fullDenom += g1D[wy+r] * g1D[wx+r]; if (yInterior && xInterior) { // GetIndex1D() 없음 + fullDenom 재사용 outB[y,x] = sB / fullDenom; // ← 최고속 outG[y,x] = sG / fullDenom; outR[y,x] = sR / fullDenom; } else { // 경계: 새 denom 누적 outB[y,x] = sB / localDenom; }
sigmaFactor(σF)는 가우시안 커널의 폭을 제어합니다. σF가 클수록 σ가 작아져 중앙에 집중, σF가 작을수록 σ가 커져 넓게 분산.
| r | windowSize | σF=3 (넓음) | σF=6 (기본) | σF=12 (좁음) |
|---|---|---|---|---|
| 1 | 3 | σ=1.00 | σ=0.50 ⚠ 과집중 | σ=0.25 |
| 2 | 5 | σ=1.67 | σ=0.83 ✓ | σ=0.42 |
| 3 | 7 | σ=2.33 | σ=1.17 ✓ | σ=0.58 |
| 5 | 11 | σ=3.67 | σ=1.83 ✓ | σ=0.92 |
// 코드에서의 구현 — ApplyGaussian8() 시작 부분 int windowSize = checked(2 * r + 1); double sigma = windowSize / sigmaFactor; // σ = (2r+1) / σF var g1D = ComputeGaussian1D(windowSize, sigma); // Adaptive 경계: 축소된 윈도우에 맞는 σ를 재계산 double sigmaLocalX = winW / sigmaFactor; // winW < windowSize double sigmaLocalY = winH / sigmaFactor; var gx = ComputeGaussian1D(winW, sigmaLocalX); var gy = ComputeGaussian1D(winH, sigmaLocalY);
16-bit 파이프라인에서는 Interior 픽셀의 분모를 1회만 사전 계산하여 모든 내부 픽셀에 재사용합니다.
// ApplyGaussianPlanes16 시작 부분 double fullDenom = 0; for (int wy = -r; wy <= r; wy++) { double wyW = g1D[wy + r]; for (int wx = -r; wx <= r; wx++) fullDenom += wyW * g1D[wx + r]; // 1회 O((2r+1)²) } // Parallel.For 내부 if (yInterior && x >= r && x < width - r) { outB[y,x] = sB / fullDenom; // 재사용 — per-pixel 재계산 없음 outG[y,x] = sG / fullDenom; outR[y,x] = sR / fullDenom; } else { // 경계: denom을 루프에서 직접 누적 outB[y,x] = sB / localDenom; }
Gaussian Average 필터의 처리 과정을 Binomial Average와 비교하며 4단계로 분해합니다.
σ = windowSize / sigmaFactor로 σ를 결정하고, exp(−x²/2σ²)를 계산한 뒤 합=1로 정규화. Binomial의 Dictionary 캐싱과 달리 매번 재계산.
Binomial Average와 동일한 외적 패턴. 행 가중치 × 열 가중치를 곱해 2D 가중치를 on-the-fly 생성. 별도 2D 배열 할당 없음.
가중합(sumB = Σ w×pixel)과 가중치 합(denom = Σ w)을 누적한 뒤 나눗셈. R·G·B 채널 독립 처리.
8-bit: ClampToByte(sumB / denom) → 24bpp. 16-bit: 실수 결과 → ClampToUShort() → 48bpp.
이항 계수(정수 재귀)와 달리 가우시안은 매번 exp() 함수를 호출합니다. 합=1 정규화가 핵심이며, 캐싱되지 않습니다.
// ComputeGaussian1D(len, sigma) 전체 코드 private static double[] ComputeGaussian1D( int len, double sigma) { var w = (len - 1) / 2; // = r var g = new double[len]; double sum = 0.0; double twoSigmaSq = 2 * sigma * sigma; for (int i = 0; i < len; i++) { int x = i - w; // 중앙 기준 오프셋 double v = Math.Exp( -(x * x) / twoSigmaSq); g[i] = v; sum += v; // 정규화용 합 } // ★ 합=1 정규화 — 밝기 보존 for (int i = 0; i < len; i++) g[i] /= sum; return g; }
| 항목 | Binomial | Gaussian |
|---|---|---|
| 생성 함수 | GetBinomial1D(n) | ComputeGaussian1D(len, σ) |
| 핵심 연산 | c[i]=c[i-1]*(n-i)/i | exp(−x²/2σ²) |
| 캐싱 | Dictionary<int,double[]> ✓ | 없음 ✗ |
| 파라미터 | n (windowSize만) | len + sigma |
| 정규화 | 원시 정수 계수 (외부 denom) | 내부 합=1 정규화 |
| Adaptive 경계 | GetBinomial1D(winW) 캐싱 | 매번 재계산 |
AdaptiveMask는 원래 가우시안 커널을 유지하되 범위 밖 픽셀만 건너뛰고, denom을 유효 픽셀 가중치만으로 재정규화합니다.
// ApplyGaussian8 — AdaptiveMask 분기 else if (mode == BoundaryMode.AdaptiveMask) { double sumB = 0, sumG = 0, sumR = 0; double denom = 0; for (int wy = -r; wy <= r; wy++) { int ny = y + wy; if ((uint)ny >= (uint)height) continue; // ↑ unsigned 비교로 음수&초과 한 번에 검사 double wyW = g1D[wy + r]; // 원본 가중치 유지 for (int wx = -r; wx <= r; wx++) { int nx = x + wx; if ((uint)nx >= (uint)width) continue; double w = wyW * g1D[wx + r]; // 2D 외적 int p = (ny * sStride) + (nx * 3); sumB += w * sBuffer[p]; sumG += w * sBuffer[p + 1]; sumR += w * sBuffer[p + 2]; denom += w; // 유효 픽셀만 denom에 반영 } } if (denom <= 0) denom = 1; dBuffer[d] = ClampToByte(sumB / denom); dBuffer[d+1] = ClampToByte(sumG / denom); dBuffer[d+2] = ClampToByte(sumR / denom); }
Gaussian Average에서 분모(denom)는 경계 모드에 따라 다르게 계산됩니다. Binomial Average와 동일한 패턴입니다.
| 경로 | denom 계산 | 코드 |
|---|---|---|
| Interior (16-bit) |
fullDenom (1회 사전계산) | for(wy)for(wx) fullDenom += g1D[wy+r]*g1D[wx+r] |
| Adaptive | 축소 커널의 2D 합 | gx=ComputeGaussian1D(winW, winW/σF)denom=Σgy[yy]*gx[xx] |
| AdaptiveMask | 유효 픽셀 가중치만 누적 | if((uint)ny>=height) continue;denom += w; |
| ZeroPad | 전체 가중치 누적 (밖도 포함) | if(nx<0){denom+=w; continue;} |
| Symmetric/Replicate | 전체 가중치 누적 | 모든 nx 유효 → denom += w |
Adaptive 모드에서 경계 픽셀은 축소된 윈도우에 맞는 새로운 가우시안 커널을 생성합니다. σ도 축소된 윈도우에 비례하여 재계산됩니다.
// ApplyGaussian8 — Adaptive 분기 int left = Math.Min(r, x); int right = Math.Min(r, width - 1 - x); int winW = left + right + 1; // ★ σ도 축소된 윈도우에 비례하여 재계산 double sigmaLocalX = winW / sigmaFactor; double sigmaLocalY = winH / sigmaFactor; var gx = ComputeGaussian1D(winW, sigmaLocalX); var gy = ComputeGaussian1D(winH, sigmaLocalY); // 예: 모서리(x=0, y=0) r=2 // winW=3, σX=3/6=0.5 → 더 뾰족한 가우시안 // winH=3, σY=3/6=0.5 → 마찬가지 // denom 재계산 (합=1 정규화이므로 ~1.0) double denom = 0; for (yy) for (xx) denom += gy[yy] * gx[xx]; if (denom <= 0) denom = 1;
윈도우 내 픽셀에 다항식을 최소제곱 피팅하여 중앙값을 추정합니다. 피크·엣지를 가장 잘 보존하며, derivOrder>0이면 그래디언트(엣지 감지)도 출력합니다.
SG는 외적 대신 2-Pass 분리 합성곱을 사용합니다:
// ExtractPlanes(src, false) → double[,] planes // derivOrder=0 (스무딩) Parallel.For 2회 Parallel.For(0, h, y => { for (x) { tmpB[y,x] = Convolve1D_X(planes.B, y, x, w, r, coeffSmooth, mode, polyOrder, 0); } }); Parallel.For(0, h, y => { for (x) outB[y,x] = Convolve1D_Y(tmpB, y, x, h, r, coeffSmooth, mode, polyOrder, 0); }); // R·G·B 채널 독립 반복 (tmpR, tmpG, tmpB 별도)
// derivOrder=0: 스무딩만 (totalPasses=2) Parallel.For(0, h, y => { for (x) tmpB[y,x] = Convolve1D_X(planes.B, y, x, coeffSmooth, r, BoundaryMode); }); Parallel.For(0, h, y => { for (x) outB[y,x] = Convolve1D_Y(tmpB, y, x, coeffSmooth, r, BoundaryMode); }); // derivOrder>0: 그래디언트 출력 (totalPasses=12) // gx = X-deriv × Y-smooth // gy = X-smooth × Y-deriv // out = √(gx²+gy²) per channel
SG 필터는 derivOrder=0(스무딩)과 derivOrder>0(그래디언트)에서 처리 경로가 완전히 다릅니다. 스무딩은 2-pass, 그래디언트는 12-pass(채널×4)입니다.
// ApplySavitzkyGolaySeparable8 — totalPasses 계산 int totalPasses; if (isSg) totalPasses = (derivOrder == 0) ? 2 : 12; // 스무딩 2 / 그래디언트 12 else totalPasses = 1; var proxy = new ProgressProxy(progress, checked(totalPasses * height)); // ↑ 총 진행 단위 = pass 수 × 이미지 높이
SG 필터의 핵심은 다항식 최소제곱 피팅의 계수를 합성곱 가중치로 변환하는 것입니다. 이 과정은 Vandermonde 행렬 → QR 분해 → 역대입으로 이루어집니다.
A[i,j] = xᵢʲ (xᵢ = −r,...,0,...,+r). 각 행은 윈도우 내 한 점의 다항식 기저값. windowSize × (polyOrder+1) 크기.
작업 복사본 W에 열 단위 Householder 반사 적용. Q는 명시 저장하지 않고 Householder 벡터(vecs[k])만 보관하여 메모리 절약. 부호 선택으로 catastrophic cancellation 방지.
R의 전치에 대해 단위 벡터 e_derivOrder를 풀어 z 벡터를 구합니다. derivOrder=0이면 e₀=[1,0,...,0].
[z; 0] 벡터에 Householder 반사를 역순(k=cols-1 → 0)으로 적용하여 최종 합성곱 계수 h를 복원합니다.
derivOrder=0: 계수 합=1로 정규화 (밝기 보존). derivOrder>0: derivOrder!/δ^derivOrder로 스케일링 (미분 값 보정).
윈도우 내 각 위치 x에 대해 다항식 기저 [1, x, x²]를 행으로 구성합니다. 비대칭 윈도우(경계)에서는 x 범위가 [−left, +right]로 조정됩니다.
| x | x⁰ = 1 | x¹ | x² |
|---|---|---|---|
| −2 | 1 | −2 | 4 |
| −1 | 1 | −1 | 1 |
| 0 | 1 | 0 | 0 |
| +1 | 1 | +1 | 1 |
| +2 | 1 | +2 | 4 |
// ComputeSavitzkyGolayCoefficients() int m = polyOrder; // = 2 int half = windowSize / 2; // = r var A = new double[windowSize, m+1]; for (int i = -half; i <= half; i++) { double x = i; double pow = 1.0; for (int j = 0; j <= m; j++) { A[i + half, j] = pow; // xʲ pow *= x; } } // → ComputeSgCoefficientsViaQR( // A, windowSize, m+1, // derivOrder, delta)
일반적인 (AᵀA)⁻¹Aᵀ 정규 방정식이 아닌 Householder QR 경로를 사용합니다. AᵀA의 조건수 = A의 조건수²이므로, 높은 polyOrder에서 수치적 안정성을 위해 QR이 필수적입니다.
// 작업 복사본 W = A var W = new double[windowSize, cols]; var vecs = new double[cols][]; for (int k = 0; k < cols; k++) { int len = windowSize - k; var v = new double[len]; for (int i = 0; i < len; i++) v[i] = W[k + i, k]; double norm = Math.Sqrt(sigma); // 부호 선택: catastrophic cancellation 방지 double alpha = v[0] >= 0 ? -norm : norm; v[0] -= alpha; // v 정규화 double vnorm2 = 0; for (i) vnorm2 += v[i] * v[i]; double inv = 1.0 / Math.Sqrt(vnorm2); for (i) v[i] *= inv; vecs[k] = v; // 나머지 열 반사: // W[k:,j] -= 2·v·(vᵀ·W[k:,j]) for (int j = k; j < cols; j++) { double dot = 0; for (i) dot += v[i] * W[k+i,j]; dot *= 2.0; for (i) W[k+i,j] -= dot * v[i]; } } // W[0..cols-1, 0..cols-1] → R (상삼각)
var z = new double[cols]; for (int i = 0; i < cols; i++) { double rhs = (i == derivOrder) ? 1.0 : 0.0; for (int j = 0; j < i; j++) rhs -= W[j, i] * z[j]; // Rᵀ[i,j] = R[j,i] = W[j,i] z[i] = rhs / W[i, i]; }
var h = new double[windowSize]; for (j < cols) h[j] = z[j]; // h[cols..] 은 0 유지 for (int k = cols-1; k >= 0; k--) { var v = vecs[k]; int len = windowSize - k; double dot = 0; for (i) dot += v[i] * h[k+i]; dot *= 2.0; for (i) h[k+i] -= dot * v[i]; } // STEP 5: 정규화 / 스케일링 if (derivOrder == 0) { double s = Σh[i]; for (i) h[i] /= s; // 합=1 보존 } else { double scale = FactorialAsDouble(derivOrder) / Math.Pow(delta, derivOrder); for (i) h[i] *= scale; }
SG 필터의 고유한 특성은 음수 계수의 존재입니다. 이것이 다른 모든 필터(Rect, Binomial, Gaussian)와 근본적으로 다른 점입니다.
SG는 윈도우 내 데이터에 다항식을 피팅합니다:
음수 계수는 피팅 과정에서 자연 발생합니다. 먼 픽셀의 값을 빼는 것으로 엣지와 피크 형태를 복원합니다.
각 1D 합성곱 함수는 3가지 경로로 분기됩니다: Interior 빠른 경로, Adaptive/ValidOnly/AdaptiveMask 비대칭 SG 경로, 기존 경계 모드(Symmetric/Replicate/ZeroPad) 경로.
// x >= r && x < width - r // GetIndex1D() 호출 없음! double accFast = 0.0; for (int k = -r; k <= r; k++) accFast += coeff[k + r] * src[y, x + k]; return accFast; // ↑ 단순 내적 — 경계 검사 없음 // 대부분의 픽셀이 이 경로
// 경계: 비대칭 SG 계수 재계산 int left = Math.Min(r, x); int right = Math.Min(r, width-1-x); int start = x - left; // polyOrder/derivOrder 축소 int localWindow = left + right + 1; int localPoly = Math.Min( polyOrder, localWindow - 1); int localDeriv = Math.Min( derivOrder, localPoly); var h = (localDeriv == 0) ? GetAsymmetricSg(left, right, localPoly) : GetAsymmetricSgDeriv( left, right, localPoly, localDeriv); double sum = 0; for (int i = 0; i < h.Length; i++) sum += h[i] * src[y, start + i]; return sum;
// 기존 경계 모드: GetIndex1D()로 인덱스 매핑 double acc = 0.0, denom = 0.0; for (int k = -r; k <= r; k++) { int nx = GetIndex1D(x + k, width, mode); double c = coeff[k + r]; if (nx < 0) { if (mode == ZeroPad) denom += c; continue; } acc += c * src[y, nx]; denom += c; } // derivOrder > 0: 미분 계수 합≈0이므로 acc 그대로 반환 if (derivOrder > 0) return acc; // ZeroPad derivOrder=0: denom으로 재정규화 if (mode == BoundaryMode.ZeroPad) { if (Math.Abs(denom) < 1e-12) return src[y, x]; return acc / denom; } return acc; // Symmetric/Replicate: denom≈1
경계 픽셀에서 좌/우(또는 상/하) 가용 폭이 다르므로 비대칭 Vandermonde 행렬로 계수를 재계산합니다. 동일한 (left, right, polyOrder) 조합은 Dictionary에 캐싱됩니다.
// 스무딩 계수 캐시 private static readonly Dictionary<Tuple<int,int,int>, double[]> _sgAsymSmoothCache; private static double[] GetAsymmetricSg( int left, int right, int polyOrder) { var key = Tuple.Create( left, right, polyOrder); lock (_sgAsymSmoothCacheLock) { if (_sgAsymSmoothCache .TryGetValue(key, out var c)) return c; // 캐시 적중 c = ComputeSGAsymmetric( left, right, polyOrder, derivOrder:0, delta:1.0); _sgAsymSmoothCache[key] = c; return c; } }
// 미분 계수 캐시 (4-tuple 키) private static readonly Dictionary<Tuple<int,int,int,int>, double[]> _sgAsymDerivCache; private static double[] GetAsymmetricSgDeriv( int left, int right, int polyOrder, int derivOrder) { var key = Tuple.Create( left, right, polyOrder, derivOrder); lock (_sgAsymDerivCacheLock) { if (cache.TryGetValue(key, out var c)) return c; c = ComputeSGAsymmetric( left, right, polyOrder, derivOrder, delta:1.0); cache[key] = c; return c; } }
localPoly = Math.Min(polyOrder, localWindow − 1)로 축소되어 Vandermonde 행렬이 특이(singular)해지는 것을 방지합니다. ValidateSmoothingParameters()에서 사전 검증도 수행됩니다.derivOrder>0이면 X/Y 방향 미분을 합성하여 엣지 강도 맵을 생성합니다. 채널당 4-pass × 3채널 = 12-pass.
// ComputeGradientMagnitudeForChannel() — 채널당 4-pass var tmp = new double[h, w]; var gx = new double[h, w]; // Pass 1: X 방향 미분 Parallel.For(0, h, y => { for (x) tmp[y,x] = Convolve1D_X(src, y, x, w, r, coeffDeriv, mode, polyOrder, derivOrder); proxy?.StepRows(1); }); // Pass 2: Y 방향 스무딩 Parallel.For(0, h, y => { for (x) gx[y,x] = Convolve1D_Y(tmp, y, x, h, r, coeffSmooth, mode, polyOrder, 0); proxy?.StepRows(1); }); // Pass 3: X 방향 스무딩 Parallel.For(0, h, y => { for (x) tmp[y,x] = Convolve1D_X(src, y, x, w, r, coeffSmooth, mode, polyOrder, 0); proxy?.StepRows(1); }); // Pass 4: Y 방향 미분 + 합성 Parallel.For(0, h, y => { for (x) { double gyVal = Convolve1D_Y(tmp, y, x, h, r, coeffDeriv, mode, polyOrder, derivOrder); double gxVal = gx[y,x]; dst[y,x] = Math.Sqrt(gxVal*gxVal + gyVal*gyVal); } proxy?.StepRows(1); });
| 특성 | SG | Rect | Binomial Avg | Gaussian |
|---|---|---|---|---|
| 가중치 생성 | QR 분해 (다항 회귀) | 균일 1/(2r+1)² | 파스칼 삼각형 | exp(−x²/2σ²) |
| 음수 계수 | 있음 ← 핵심 | 없음 | 없음 | 없음 |
| 2D 적용 | X→Y 분리 합성곱 | 2D 직접 루프 | 2D 외적 곱셈 | 2D 외적 곱셈 |
| 엣지 보존 | 최고 | 최저 | 보통 | 보통 |
| 피크 보존 | 최고 | 최저 | 보통 | 보통 |
| 미분 출력 | 지원 (derivOrder) | 미지원 | 미지원 | 미지원 |
| 경계 처리 | 비대칭 SG 재계산 | GetIndex1D | 축소 Binomial | 축소 Gaussian |
| 계산 복잡도 | O(r) per pixel (1D) | O(r²) | O(r²) | O(r²) |
| 파라미터 | r + polyOrder + derivOrder | r | r | r + sigmaFactor |
커널이 이미지 경계를 벗어날 때 범위 밖 픽셀을 어떻게 처리할지 결정합니다. X·Y 방향에 GetIndex1D()가 독립 적용됩니다.
반경 r인 커널을 이미지 가장자리 픽셀에 적용하면, 커널의 일부가 이미지 밖(-1, -2, ... 또는 w, w+1, ...) 좌표를 참조합니다. 이 범위 밖 픽셀에 어떤 값을 할당하느냐가 BoundaryMode입니다.
| Mode | 반환 | 의미 |
|---|---|---|
| Symmetric | 0 | idx=0 (거울) |
| Replicate | 0 | 가장자리 복제 |
| ZeroPad | -1 | → 값 0 사용 |
| AdaptiveMask | -1 | → 스킵, 재정규화 |
| Adaptive | 0 | Symmetric과 동일 (필터에서 별도 분기) |
private static int GetIndex1D(int idx, int n, BoundaryMode mode) { switch (mode) { case Symmetric: return idx < 0 ? -idx-1 : idx >= n ? 2*n-idx-1 : idx; case Replicate: return idx < 0 ? 0 : idx >= n ? n-1 : idx; case ZeroPad: return (idx < 0 || idx >= n) ? -1 : idx; // -1 → 값 0 case ValidOnly: return (idx < 0 || idx >= n) ? -1 : idx; // -1 → skip case AdaptiveMask: return (idx < 0 || idx >= n) ? -1 : idx; // -1 → skip case Adaptive: return idx < 0 ? -idx-1 : idx >= n ? 2*n-idx-1 : idx; // Symmetric과 동일 (필터에서 별도 분기) } }
GetIndex1D()는 X축과 Y축에 독립적으로 호출되어 2D 경계를 처리합니다.
가장 자연스러운 연속성. idx=−1→0, idx=−2→1. (경계 픽셀 중복)
경계 픽셀 반복. 경계 밝기 평탄화 효과.
범위 밖=0, denom에 포함. 경계 어두워짐.
winW = left+right+1;
// GetBinomial1D(winW) 재생성실제 가용 영역만 사용. 경계 효과 없음.
if ((uint)ny >= (uint)h) continue; if ((uint)nx >= (uint)w) continue;
유효 픽셀만 누적 → denom 자동 재정규화.
// SG에서 GetAsymmetricSg() 호출 // 경계 비대칭 계수를 캐시에서 로드
SG 전용: 경계마다 비대칭 계수 계산 후 캐시.
| Mode | 경계 왜곡 | 속도 | 가중치 재계산 | 권장 용도 |
|---|---|---|---|---|
| Symmetric | 거의 없음 | 빠름 | 불필요 | 일반 용도 (기본값) |
| Replicate | 약간 | 빠름 | 불필요 | 경계 밝기 평탄화 |
| Adaptive | 없음 | 보통 | winW/H마다 재생성 | Binom/Gauss 경계 품질 |
| AdaptiveMask | 없음 | 보통 | 불필요(자동) | 마스크 영역 처리 |
| ZeroPad | 어두워짐 | 빠름 | 불필요 | 주파수/신호 처리 |
| ValidOnly | 없음 | 보통 | SG: 비대칭 계수 | SG + 엣지 감지 |
동일한 BoundaryMode라도 각 필터가 경계를 처리하는 방식은 다릅니다. 특히 Adaptive 모드에서 가중치 재계산 방식이 필터마다 크게 다릅니다.
| 필터 | Symmetric/Replicate | Adaptive | AdaptiveMask | ValidOnly/ZeroPad |
|---|---|---|---|---|
| Rectangular | GetIndex1D → 균일 합산 count=(2r+1)² 고정 |
윈도우 축소 count=winW×winH |
uint 범위 검사 count 감소 |
범위 밖 skip/0 count 변동 |
| Binomial Avg | GetIndex1D → 이항 가중합 denom=Σw 전체 |
GetBinomial1D(winW) 축소 계수 재생성 (캐싱) |
uint 범위 검사 denom=유효 w만 |
범위 밖 skip/0 denom 변동 |
| Binom. Median | GetIndex1D → Bucket 전체 가중치 |
GetBinomial1D(winW) 축소 계수 + Bucket |
uint 검사 → Bucket 유효 샘플만 |
범위 밖 skip count 감소 |
| Gauss. Median | GetIndex1D → Bucket 전체 가중치 |
ComputeGaussian1D(winW,σ) σ도 비례 축소 (캐싱 없음) |
uint 검사 → Bucket 유효 샘플만 |
범위 밖 skip count 감소 |
| Gaussian Avg | GetIndex1D → 가중합 denom=Σw 전체 |
ComputeGaussian1D(winW,σ) σ 비례 축소 (캐싱 없음) |
uint 검사 denom=유효 w만 |
범위 밖 skip/0 denom 변동 |
| Savitzky-Golay | GetIndex1D → 계수 내적 denom≈1 (합=1 정규화) |
GetAsymmetricSg(l,r,p) 비대칭 QR 분해 (캐싱) |
비대칭 SG 계수 캐싱 |
비대칭 SG 계수 캐싱 |
SG 필터는 경계에서 다른 필터와 근본적으로 다른 접근을 합니다. 단순 축소가 아닌 비대칭 Vandermonde 행렬로 QR 분해를 수행합니다.
// Convolve1D_X — Adaptive/ValidOnly 경계 경로 int left = Math.Min(r, x); int right = Math.Min(r, width - 1 - x); int localWindow = left + right + 1; // polyOrder/derivOrder 자동 축소 (과적합 방지) int localPoly = Math.Min(polyOrder, localWindow - 1); int localDeriv = Math.Min(derivOrder, localPoly); // 비대칭 SG 계수 (Dictionary 캐싱) double[] h = (localDeriv == 0) ? GetAsymmetricSg(left, right, localPoly) // 스무딩 캐시 : GetAsymmetricSgDeriv(left, right, localPoly, localDeriv); // 미분 캐시 // 비대칭 계수로 합성곱 double sum = 0; int start = x - left; for (int i = 0; i < h.Length; i++) sum += h[i] * src[y, start + i]; return sum;
public static string GetBoundaryMethodText(BoundaryMode mode) { switch (mode) { case Symmetric: return "Symmetric (Mirror)"; case Replicate: return "Replicate (Edge Clamp)"; case ZeroPad: return "Zero Padding"; case Adaptive: return "Adaptive (Window Shrink)"; case AdaptiveMask: return "Adaptive Mask (Skip Invalid)"; case ValidOnly: return "Valid Only"; } }
private static readonly Dictionary<int, double[]> _binom1D; lock (_lock) { if (_binom1D.TryGetValue(n, out var c)) return c; // 동일 windowSize 재계산 없음 }
using var tBufs = new ThreadLocal<MedianThreadBuffers8>( () => new MedianThreadBuffers8(maxSamples)); // 스레드별 독립 버퍼 → lock 없음 // bucket[256] 재사용 → GC 없음
각 행(row)이 완전히 독립적. 입력에 읽기 전용 접근이므로 경쟁 없이 완전 병렬화됩니다. Parallel.For(0, height, y => { ... })
bool yIn = y>=r && y<h-r; if (yIn && x>=r && x<w-r) { // GetIndex1D() 없음! // fullDenom 재사용! }
// Dictionary<Tuple<int,int,int>, double[]> // 동일 (left,right,polyOrder) 재사용 lock (_sgAsymSmoothCacheLock) { if (cache.TryGetValue(key, out var c)) return c; // 캐시 적중 }
// 원자적 진행률 업데이트 var acc = new ProgressAccumulator( progress, totalPasses * height); // Interlocked.Add → 정확한 % // 1% 변경시에만 Report() 호출
| 필터 | 픽셀당 복잡도 | 메모리 패턴 | 경계 오버헤드 | 상대 속도 |
|---|---|---|---|---|
| Rectangular | O(r²) 정수합 | byte[] 직접 | GetIndex1D 또는 Adaptive | ★★★★★ |
| Binomial Avg | O(r²) 실수합+곱 | byte[] + 1D 캐시 | GetBinomial1D 캐싱 | ★★★★☆ |
| Binom. Median | O(r²+256) Bucket | ThreadLocal 버퍼 | GetBinomial1D 캐싱 | ★★★☆☆ |
| Gauss. Median | O(r²+256) Bucket | ThreadLocal 버퍼 | ComputeGaussian1D 재계산 | ★★★☆☆ |
| Gaussian Avg | O(r²) 실수합+곱 | byte[] + g1D | ComputeGaussian1D 재계산 | ★★★★☆ |
| Savitzky-Golay | O(r)×2 pass 1D 합성곱 | double[,] Planes + tmp | GetAsymmetricSg + QR 캐싱 | ★★☆☆☆ |
using ThreadLocal → Dispose 보장Array.Clear 후 재사용double[,] tmp — height×width 임시 배열Planes(double[,]×3) — ExtractPlanesComputeGaussian1D — 매번 new double[]gx[h,w] 추가 배열 (derivOrder>0)