Rectangular 필터부터 Savitzky-Golay 필터까지 여섯가지 필터의 수학적 원리, α 블렌딩, 경계 처리, RGB 채널 독립 처리, 8-bit / 16-bit 구현을 코드와 다이어그램으로 자세하게 심층 분석합니다.
여섯 가지 Smoothing 필터 × 8 가지 커널 반경을 실시간으로 비교합니다. 테스트 이미지를 선택하고 슬라이더를 움직이거나 필름 스트립을 클릭하세요.
| 영역 | 일치 여부 | 비고 |
|---|---|---|
| 이항 계수 생성 | 완전 일치 | 파스칼의 삼각형 기반의 동일 알고리즘 |
| 가우시안 커널 | 완전 일치 | exp(−x² / 2σ²) 정규화 동일 |
| 가중 중앙 값 (Pixel Simulator) | 완전 일치 (8-bit) | Bucket 방식, ε = total × 1e-12, (i + k + 1) >> 1 TieBreak 포함 - C# WeightedMedianDouble8 (8-bit 파이프라인) 와 동일 |
| 8-bit vs 16-bit TieBreak 보간 | 파이프라인 간 차이 | 8-bit : bucket 탐색 후 (i + k + 1) >> 1 정수 반올림 보간 (byte 출력 특성). 16-bit (WeightedMedianSorted16) : 정렬 배열에서 인접 원소와 (lo + hi) / 2.0 부동소수점 평균. TieBreak 경계 조건에서 최대 0.5 LSB 차이 발생 가능 - JS Pixel Simulator 는 8-bit 경로에 해당 |
| 경계 모드 인덱싱 | 완전 일치 | GetIndex1D() 로직 동일 |
| SG 계수 (polyOrder = 2) | 수학적 동치 | 해석적 공식 ↔ QR 분해 (polyOrder = 2 에서 수치적으로 완전 동일; "근사"가 아님) |
| 분리형 합성곱 구조 | 완전 일치 | X pass → Y pass 순서 동일 |
| Filter Lab 가중 중앙 값 2D | 단순화 (보간 생략) | acc >= half 즉시 반환. C# WeightedMedianDouble8 은 ε = total × 1e-12 허용치 + 다음 비어있지 않은 버킷과 (i + k + 1) >> 1 보간 수행 → 경계 케이스에서 최대 ±1 픽셀 차이 가능 |
| Filter Lab 경계 모드 | Replicate 고정 | conv2d · wmed2d 모두 Math.max(0, Math.min(w - 1, ...)) - C# 기본값은 Symmetric. 실제 : 6 가지 선택 가능 |
sgW() 는 다음 완전한 분수 공식으로 계수를 계산합니다 : h(j) = [3m(m + 1) − 1 − 5j²] / [(2m − 1)(2m + 1)(2m + 3) / 3] (분자 : 각 위치의 비정규화 가중치, 분모 : 정규화 상수 = 분자의 합산값과 항등 - 코드에서는 실제 합산으로 계산).
Gaussian 은 σF = 6 고정 (σ = windowSize / 6) 입니다 - 픽셀 시뮬레이터의 슬라이더와 달리 Filter Lab 에서는 변경되지 않습니다.
Binomial / Gaussian Weighted Median (wmed2d) 의 중앙 값 보간이 생략되어 있습니다 - C# WeightedMedianDouble8 은 누적 가중치가 ½ 에 걸칠 때 다음 비어 있지 않은 버킷과 (i + k + 1) >> 1 보간을 수행하지만, Filter Lab 은 acc >= half 를 즉시 반환하므로 경계 케이스에서 최대 ±1 픽셀 차이가 발생할 수 있습니다.
3 채널 독립 처리의 시각적 영향 :
실제 SonataSmooth 는 R · G · B 채널 각각에 대해 독립적으로 가중 중앙 값을 계산합니다.
동일한 윈도우 내에서도 채널마다 누적 가중치 분포가 달라 중앙 값이 선택되는 픽셀 위치가 채널별로 불일치할 수 있습니다.
예를 들어 엣지 근처에서 R 채널 중앙 값은 좌측 픽셀을, G 채널 중앙 값은 우측 픽셀을 선택하면 출력 픽셀의 색상이 원본과 다른 색조 (Hue) 로 이동하는 컬러 시프트 아티팩트가 발생할 수 있습니다.
JS 시뮬레이터는 단일 채널 (그레이스케일 등가) 만 연산하므로 이 채널 간 중앙 값 위치 불일치 현상은 재현되지 않습니다.
SonataSmooth 핵심 애플리케이션 코드, .NET Standard 2.0 기반 Smoothing 라이브러리 NuGet 패키지, 그리고 라이브러리 기능을 직접 실험해볼 수 있는 실습용 Etude 애플리케이션을 아래 링크에서 확인하실 수 있습니다.
SonataSmooth 의 핵심 소스 코드 저장소입니다. C# .NET Windows Forms 기반으로 제작된 수치 데이터 노이즈 제거 및 평활화 애플리케이션으로, 수동 입력, 클립보드 붙여넣기, 드래그 앤 드롭 등 다양한 입력 방식과 강력한 유효성 검사를 지원합니다. Rectangular Mean, Binomial Average, Binomial Median, Gaussian Weighted Median, Gaussian, Savitzky-Golay 필터를 파라미터를 세부 조정하며 함께 적용할 수 있으며, 실시간 진행 피드백과 배치 편집 기능을 갖춘 반응형 UI 를 제공합니다.
머신러닝 · 딥러닝 전처리, IoT 센서 데이터 처리, 금융 시계열 필터링, 과학 실험 측정값 정제, 1D 신호 처리, 데이터 시각화 등 순차적 수치 신호의 전처리가 필요한 다양한 도메인에서 활용할 수 있습니다.
SonataSmooth 은 "Sonata" 와 "Smooth" 의 합성어입니다. 소나타 (Sonata) 는 서로 다른 악장 (악장 = 알고리즘) 이 하나의 조화로운 전체로 어우러지는 음악 형식으로, 여러 Smoothing 알고리즘이 협주 (concert) 하면서 함께 작동한다는 철학을 담고 있습니다. "Smooth" 는 데이터에서 노이즈를 부드럽게 제거하는 평활화 기능을 강조합니다. SonataSmooth 는 다양한 기법을 조화롭게 적용하여 데이터를 음악처럼 매끄럽고 명확하게 처리한다는 철학을 구현합니다.
대부분의 컴포넌트는 MIT 라이선스 적용. 단, 파스칼의 삼각형 계수 기반 Binomial Weighted Median 필터는 특허 출원 중 (patent-pending) 으로, 비상업적 연구 · 교육 · 개인 프로젝트에는 무료로 사용 가능하나 상업적 사용 · 재배포 · 제품 통합은 특허 보유자의 사전 서면 동의가 필요합니다.
.NET Standard 2.0 을 기반으로 하는 고성능 1D 수치 신호 평활화 & 내보내기 툴킷 NuGet 패키지입니다. Rectangular (Moving Average), Binomial (Pascal), Weighted Median (Binomial Weights), Gaussian Weighted Median (GWMF), Gaussian, Savitzky-Golay Smoothing 을 다양한 경계 처리 옵션과 병렬화 설정과 함께 제공하며, CSV 및 Excel (COM) 내보내기 Helper 로 여러 Smoothing 결과를 나란히 비교 · 차트화할 수 있습니다.
대부분의 컴포넌트는 MIT 라이선스 적용. 단, Pascal's Triangle 계수 기반 Binomial Weighted Median 필터는 특허 출원 중(patent-pending)으로, 비상업적 연구 · 교육 · 개인 프로젝트에는 무료로 사용 가능하나 상업적 사용 · 재배포 · 제품 통합은 특허 보유자의 사전 서면 동의가 필요합니다.
SonataSmooth.Tune 라이브러리의 기능을 직접 체험하고 학습할 수 있는 실습용 동반 애플리케이션입니다. 음악에서 에튀드 (Étude) 가 최종 작품을 위한 준비 연습곡이듯, 이 저장소는 본격적인 데이터 처리 애플리케이션을 구현하기 전에 SonataSmooth.Tune의 다양한 Smoothing 알고리즘을 실험하고 이해하기 위한 스케치이자 학습 공간입니다.
Rectangular, Binomial Average, Binomial Weighted Median, Gaussian Weighted Median (GWMF), Gaussian, Savitzky-Golay 등
다양한 필터의 동작을 샘플 데이터로 직접 실행해보고 결과를 비교할 수 있으며,
경계 처리 방식 선택, 병렬화 옵션, CSV / Excel 내보내기 등 실제 워크플로에 필요한 기능도 함께 다룹니다.
저장소에는 Samples/ 폴더와 doc/ 문서가 포함되어 있어
라이브러리 도입 전 검증 단계로 활용하기에 최적입니다.
SonataSmooth.Tune 은 데이터를 악기 조율하듯 정밀하게 다듬는 C# / .NET Smoothing 라이브러리이며, SonataSmooth.Tune.Etude 는 SonataSmooth.Tune 라이브러리를 위한 연습곡 (Étude) - 완성된 작품에 앞서 기법을 탐구하고 숙달하는 실습 공간입니다.
대부분의 컴포넌트는 MIT 라이선스 적용. 단, Pascal's Triangle 계수 기반 Binomial Weighted Median 필터는 특허 출원 중(patent-pending)으로, 비상업적 연구 · 교육 · 개인 프로젝트에는 무료로 사용 가능하나 상업적 사용 · 재배포 · 제품 통합은 특허 보유자의 사전 서면 동의가 필요합니다.
여섯 가지 필터는 모두 하나의 진입점 ApplySmoothing() 을 통해 호출됩니다. 비트심도 (8 / 16-bit) 에 따라 파이프라인이 분기되고, 각 필터는 R · G · B 세 가지 채널을 채널별로 각각 분리하여 처리합니다.
// Apply8() / Apply16() 내 분기 if (doRect) → ApplyRect8() / ApplyRectPlanes16() if (doAvg) → ApplyBinomialAverage8() / ApplyBinomialAveragePlanes16() if (doMed && !doGaussMed) → ApplyWeightedMedian8(Binom) / ApplyWeightedMedianPlanes16(Binom) if (doGaussMed) → ApplyWeightedMedian8(Gauss) / ApplyWeightedMedianPlanes16(Gauss) 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 혹은 Moving Average 라고도 합니다.
| 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 → 건너뜀 | count < (2r + 1)² | 유효 픽셀만 평균 |
| AdaptiveMask | 건너뜀 (GetIndex1D 없음) | count < (2r + 1)² | 유효 픽셀만 평균 |
| Adaptive | 윈도우 축소 | winW × winH | 축소된 영역만 평균 |
count 표기는 Rectangular 필터 전용입니다. Binomial Average · Gaussian 등 가중 필터에서는 정수 count 대신 실수 denom 이 분모 역할을 하며, ZeroPad 에서의 처리도 count++ 가 아닌 denom += w (범위 밖 픽셀의 가중치를 분모에 누적) 방식으로 동작합니다. 각 필터의 ZeroPad 동작 상세는 해당 필터 섹션의 denom 설명을 참조하십시오.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 : 범위 밖 건너뜀, 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는 윈도우 너비는 그대로 두되, 범위 밖은 건너뛰어 분모가 감소하고 그에 따라 재정규화.// 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 → 건너뜀 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 // left = Math.Min(r = 2, x = 0) = 0, right = Math.Min(r = 2, w - 1) = 2 // winW = 0 + 2 + 1 = 3 → cx = [1, 2, 1] 합 = 4 // winH = 0 + 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⁸ |
public const int MaxBinomialRadius = 255;2D 이항 가중치 총합은
2^(4r) 입니다. IEEE 754 double 최댓값은 ≈ 21023 이므로, 4r ≥ 1024 (즉 r ≥ 256) 이면, 2^1024 부터 Infinity 오버플로우가 발생합니다. 실제 코드는 ValidateSmoothingParameters() 에서 r > MaxBinomialRadius 일 때 ArgumentException 을 던지므로, 반경은 반드시 r ≤ 255 범위 내에서 사용해야 합니다.
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); // 1. 버킷 초기화 double total = 0; for (int i = 0; i < count; i++) { double w = weights[i]; bucket[values[i]] += w; // 2. 픽셀 값 → 버킷에 가중치 누적 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++) // 3. 0 → 255 순서 탐색 { double w = bucket[i]; if (w > 0) { acc += w; if (acc > half + eps) // 중앙 값 발견! return (byte)i; if (acc >= half - eps) // 4. 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() 시 반올림 출력.
(i + k + 1) >> 1 은 ⌈(i + k) / 2⌉ (올림 평균, 정수 버킷 인덱스 기반) 이고,
(lo + hi) / 2.0 은 부동소수점 정확 평균입니다.⌈(i + k) / 2⌉ 는 (i + k) 가 짝수이면 정확 평균과 동일하고, 홀수이면 정확 평균 X.5 를 올림한 값과 동일합니다 - 즉 round-half-up 규칙과 수치적으로 일치합니다.
16-bit 의 (lo + hi) / 2.0 도 ClampToUShort() 에서 동일한 방향의 반올림을 거치므로, 두 파이프라인이 같은 경계 조건에 놓일 경우 최종 정수 출력은 일치하거나 최대 1 LSB 차이에 그칩니다.
또한 TieBreak 자체가 acc ≈ total / 2 인 축퇴적(degenerate) 조건에서만 활성화되므로, 실용적 이미지 데이터에서 이 경로가 최종 화질에 미치는 영향은 무시 가능한 수준입니다.
Binomial Weighted Median 필터는 엣지와 원본 구조를 보존하면서 임펄스 · 랜덤 노이즈만 선택적으로 제거하는 것이 핵심 강점이며, 다양한 산업 및 연구 분야에서 실질적인 이점을 제공합니다.
균일 중앙 값 (Rectangular Median) 에서 발생하는 Cross-Hatching (격자) 왜곡 현상을 Binomial Weighted Median 필터가 어떻게 구조적으로 개선하는지 분석합니다.
C(n - 1, k) ∝ Gaussian 근사) 하므로,
대각 모서리 (길이 = √2 · r) 의 기여가 축 방향 (길이 = r) 보다 자연스럽게 감소하여 등방향 (isotropic) 응답에 근접합니다.
계산량이 많은 원형 커널을 사용하지 않고도 방향성이 왜곡되는 결과를 억제하는 구조적 해법입니다.
강한 노이즈가 발생한 이미지에 r (커널 반경) 을 증가시켜 더 넓은 영역에 대해 필터를 적용하면, 대부분의 필터는 뭉개짐 현상으로 원본 구조도 함께 왜곡됩니다. Binomial Weighted Median 필터는 r 값을 증가시켜도 원본 신호를 유지하는 독특한 강건성을 가집니다.
| r | 커널 | 중앙 2D % | 중앙 / 모서리 비 | 중앙 + 인접 4 누적 % | 원본 영향력 |
|---|
1 − max(wᵢ) 에 비례합니다.
Binomial의 2D 최대 가중치 = (C(2r, r) / 2^(2r))² 는 Stirling 근사에 의해 ≈ 1 / (π · r) 로 감쇠합니다.| 사용 사례 | 핵심 요구사항 | Binomial Weighted Median 필터가 충족하는 근거 |
|---|---|---|
| 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 Weighted Median 과 완전히 동일한 파이프라인을 가집니다. 유일한 차이는 Step 1 의 가중치 생성 함수입니다.
σ = windowSize / sigmaFactor로 σ를 계산한 뒤 exp(−x² / 2σ²) 생성. 합 = 1 정규화.
Binomial Weighted Median 의 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 Weighted 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 (Running Median) 과 유사 |
| 6 (기본) | 0.83 | ~ 0.23 | ~ 0.001 | ~ 3σ = 2.5 ≈ r | 중앙 집중 + 먼 픽셀 무시 → 균형 잡힌 Smoothing |
| 12 (좁음) | 0.42 | ~ 0.48 | ~ 10⁻⁶ | ~ 3σ = 1.3 < r | 중앙 거의 단독 → 원본에 가까움 |
모든 픽셀의 가중치가 비슷 → 단순 중앙 값(Rectangular Median) 과 유사. 노이즈 제거 강하지만 디테일도 소실. 넓은 영역의 소금 - 후추 노이즈 제거에 효과적.
중앙 픽셀이 거의 단독으로 중앙 값을 결정 → 원본 이미지에 가까움. Smoothing이 거의 없지만, 바로 인접한 극단 노이즈만 선택적으로 제거.
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 필터는 파스칼의 삼각형 자체의 수학적 특성이 자동으로 "합리적인" 가중치를 보장합니다. 사용자 오조작이 없어 사용이 직관적입니다.
Weighted Median 출력 방식에서는 가중치의 상대적 비율이 중앙 값 탐색에 영향을 줍니다. r ≤ 3 인 경우, 이항 계수의 중앙 집중도는 가우시안 (σF = 6.0) 과 오차 < 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.0) |
노이즈 제거 | 우위 |
|---|
(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.0 고정 시, r = 1 소형 커널에서만 Gaussian Weighted Median 필터가 소금 - 후추 노이즈를 원본 그대로 통과시킵니다.
핵심 명제 : "σ 계수가 일치된 조건에서, weighted median 연산에 한정하여, 모든 실용적 r(1 ~ 15) 에서 Binomial Weighted Median 필터가 미세하게 우위이다." - 이는 수학적으로 성립하는 구조적 사실입니다.
σ (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 은 가중치 기반 중앙 값 산출 방식입니다. 노이즈가 중앙에 위치할 때 주변 신호 값 혹은 픽셀 값들이 우세하게 작용하려면 중앙 가중치가 낮을수록 유리합니다. Platykurtic 특성을 가진 Binomial Weighted Median 필터는 σ 값을 동일하게 맞춘 조건에서도 상대적으로 중앙 가중치가 낮습니다.
| r | match σ | Binomial 중앙 2D | Gaussian 중앙 2D | Binomial Signal 우위비 | Gaussian Signal 우위비 | 저항력 차이 |
|---|
| 우위 근거 | 반례 가능성 | 이유 |
|---|---|---|
| Platykurtic → n_eff ↑ | 없음 | κ = 3 − 1 / r < 3 은 모든 유한 r 값에서 수학적으로 항상 성립 |
| 정수 값 double 정밀성 | 없음 | 이항 계수는 정수 값 double 형식으로 누적 시 오차 없음, exp() 근사 오차와 구조적 차이 |
| 유한 지지 (절단 없음) | 없음 | Binomial 의 정의역 = 윈도우, 이는 분포의 구조적 성질 |
| 중앙 noise 저항력 ↑ | 없음 | 중앙 비중 < Gaussian 은 같은 σ 조건의 모든 유한 r 값에서 항상 성립 |
| 상황 | Binomial Median 추천 | Gaussian Median 추천 |
|---|---|---|
| 소형 커널 r = 1 (기본 σF = 6.0) |
✓ 명백히 우위 2D 중앙 25% → 노이즈 제거 보장 |
✗ 필터 무효화 2D 중앙 61.9% > 50% → 중앙 노이즈 통과, σF 재조율 필수 |
| 소형 커널 r = 2 (기본 σF = 6.0) |
✓ 미세 우위 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.0 에서도 정상 동작, 그러나 Binomial 이 파라미터 없이 더 간단 |
| 노이즈 강도가 다양한 이미지들의 배치 처리 | ✓ 동일 r 재호출 시 캐시 효과 | exp() 기반 - 캐싱 효율이 Binomial 보다 낮음 |
| 텍스처가 복잡한 이미지 정밀 처리 | r 만 조절 가능, 유연성 낮음 | ✓ σ 로 세밀 조율 가능 (단 r = 1에서 σF > 2 사용 시 필터 무효화 주의) |
| r 이 큰 경우 (r ≥ 4) 강한 Smoothing | ✓ 전체 윈도우 고르게 활용, 캐싱 효과 극대화 | σF = 6.0 고정 시 r 값 증가 ↑ → σ = n / 6 비례적으로 증가 → 중앙 집중도 감소 (r = 4 : 2D 중앙 7.1%). r ≥ 2 인 조건에서, 모두 정상 동작하나 Binomial(r = 4 : 2D 중앙 1.5 %) 보다 중앙 집중도가 여전히 높음 |
| 가우시안 노이즈 모델 최적화 (Weighted Average 한정) |
Weighted Median 에서는 Gaussian 과 동등 - 커널 형태가 L₁ 최적성에 무관 | ※ 주의 Gaussian Weighted Average 가 Gaussian 노이즈에 L₂ MLE 이나, Weighted Median 에서는 최적성 미성립. Mean / Median 혼동 주의 |
| 파라미터 없이 빠른 적용 필요 시 | ✓ r만 설정, 즉시 사용 - 모든 r에서 안정 | σF 기본값 (6.0) 은 r = 1 에서 필터 무효화. r 값에 적합하도록 반드시 σF ≤ 2(r = 1), σF ≤ 4(r = 2) 조율 필요 |
| Adaptive 경계 모드 + 반복 Smoothing | ✓ 경계마다 캐시에서 조회 | 경계마다 exp() 재계산 과정 필요, 캐시 히트율 낮음 |
| 결론 |
r = 1 구간 : Binomial 명백한 우위
σF = 6.0 조건을 기준으로 Gaussian Weighted Median 필터의 2D 중앙 가중치 61.9% > 50% 로 필터 무효화. Binomial 은 파라미터 없이 모든 r 에서 안정적으로 노이즈 제거.
r ≥ 2 : 성능 수렴, Binomial 미세 구조적 우위 유지
r ≥ 2 부터 σF = 6.0 조건에서도 Gaussian Weighted Median 필터는 정상 동작. 단 Binomial Weighted Median 필터는 Platykurtic 특성 (n_eff 우위), 정수 산술, 파라미터 불필요 등 실용적인 사용성에서 우위 유지.
|
|
// r = 2 : [1, 4, 6, 4, 1] / 16 // 중앙 기여 : 6 / 16 = 37.5% // 최외곽 : 1 / 16 = 6.25% // 비율 : 6× 차이
// r = 2, σF = 6.0 : σ = 5 / 6.0 ≈ 0.83 // 중앙 기여(1D) : ≈ 47.9% (σF = 6.0 기준) // 최외곽(1D) : ≈ 2.7% (σF = 6.0 기준) // σF 변화로 비율 연속 조절 가능
가우시안 가중치로 가중 평균을 계산합니다. Median 이 아닌 평균 방식이므로 연속적이고 부드러운 결과가 나오는 특성이 있습니다. 이미지 처리에서 가장 널리 쓰이는 블러 (blur) 방식입니다.
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 이면 Gradient (엣지 감지) 도 출력합니다.
sgW() 는 분모를 실제 합산으로 계산하므로 두 방법은 수치적으로 동일합니다.SG 는 외적 대신 2-Pass 분리 합성곱을 사용합니다 :
// ExtractPlanes(src, false) → double[,] planes // derivOrder = 0 (Smoothing) 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 : Smoothing만 (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 : Gradient 출력 (totalPasses = 12) // gx = X-deriv × Y-smooth // gy = X-smooth × Y-deriv // out = √(gx² + gy²) per channel
coeffDeriv(derivOrder 차 미분 계수), Y 방향으로는 coeffSmooth(평활 계수) 를 적용합니다.
tmp = Convolve1D_X(src, coeffDeriv) → gx = Convolve1D_Y(tmp, coeffSmooth)tmp = Convolve1D_X(src, coeffSmooth) → gy = Convolve1D_Y(tmp, coeffDeriv)dst = √(gx² + gy²) 로 등방성 그래디언트 크기를 계산합니다. 방향 정보는 버려지고 밝기 값으로 크기만 출력됩니다.SG 필터는 derivOrder = 0 (Smoothing) 과 derivOrder > 0 (Gradient) 에서 처리 경로가 완전히 다릅니다. Smoothing 은 2-pass, 그래디언트는 12-pass (채널 × 4) 입니다.
// ApplySavitzkyGolaySeparable8 - totalPasses 계산 (RGB 8-bit 파이프라인) int totalPasses; if (isSg) totalPasses = (derivOrder == 0) ? 2 : 12; // Smoothing 2 / Gradient 12 (채널 3 × 4-pass) else totalPasses = 1; var proxy = new ProgressProxy(progress, checked(totalPasses * height)); // ↑ 총 진행 단위 = pass 수 × 이미지 높이
ApplyMono() 에서는 채널 수가 1 개의 단일 채널이므로 totalPasses 가 다릅니다.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 필터의 고유한 특성은 음수 계수의 존재이며, 다른 모든 필터 (Rectangular, Binomial Average, Binomial Weighted Median, Gaussian Weighted Median, 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 에 캐싱됩니다.
// Smoothing 계수 캐시 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 방향 Smoothing 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 방향 Smoothing 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 Average | 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 → 건너뜀 case AdaptiveMask: return (idx < 0 || idx >= n) ? - 1 : idx; // -1 → 건너뜀 case Adaptive: return idx < 0 ? - idx - 1 : idx >= n ? 2 * n - idx - 1 : idx; // Symmetric 과 동일 (필터에서 별도 분기) } }
GetIndex1D() 함수는 X 축과 Y 축에 대해 개별적으로 호출되어 2D 경계를 처리합니다.
winW / winH 를 직접 재계산해 새 커널을 생성하는 별도 분기로 진입하기 때문입니다. 따라서 "GetIndex1D 의 Adaptive case = Adaptive 처리 전체" 라고 오독하지 않도록 주의가 필요합니다. GetIndex1D 의 Adaptive 반환값은 Adaptive 전용 분기가 없는 일부 경로의 폴백 (fallback) 에만 해당합니다.
−1 을 반환하지만 실제 구현 경로가 다릅니다. ValidOnly 는 GetIndex1D() → −1 → continue 경로를 밟고, AdaptiveMask 는 GetIndex1D() 자체를 호출하지 않고 (uint) nx ≥ (uint) width 단일 비교로 처리해 함수 호출 오버헤드를 제거합니다. 최종 출력값은 동일합니다. SG 필터에서는 ValidOnly · AdaptiveMask · Adaptive 세 모드 모두 동일한 GetAsymmetricSg(left, right, polyOrder) 경로를 거치므로 결과가 동일합니다. 즉, "GetIndex1D 에서 동일한 −1 반환 = 처리 경로도 동일"은 아니며, 모드별 동작 상세는 각 모드 설명 카드를 참조하세요.
경계 밖 픽셀을 이미지 가장자리를 기준으로 거울처럼 반사하여 채웁니다. GetIndex1D() 함수가 X · Y 축에 각각 적용되어 2D 경계를 처리합니다. 경계 픽셀이 반사 원점이 되므로 idx = −1 과 idx = 0 이 동일한 픽셀을 참조합니다 (경계 픽셀 중복).
범위 밖 픽셀을 가장 가까운 가장자리 픽셀 값으로 반복하여 채웁니다. GetIndex1D() 함수는 음수 인덱스를 0 으로, n 이상 인덱스를 n − 1 로 클램핑합니다. 경계 픽셀이 커널 안에서 r 회 중복 반영되어 경계 근처에서 평탄화 (밝기 평균이 경계 값 쪽으로 끌림) 효과가 발생합니다.
GetIndex1D() 함수가 범위 밖 인덱스에 대해 −1 을 반환하며, 호출 측에서 −1 을 "값 0 을 사용하라" 는 신호로 해석합니다. 핵심은 분모 (count 또는 denom) 는 그대로 (2r + 1)² 를 유지하면서 분자에만 0 이 더해진다는 점입니다. 따라서 경계 픽셀의 평균값이 실제 픽셀 밝기보다 낮게 계산되어 경계가 어두워지는 왜곡이 발생합니다.
GetIndex1D() 함수를 호출하지 않는 대신, 경계 영역에서 가용한 범위만큼 윈도우를 물리적으로 줄인 뒤 축소된 크기 winW × winH 에 맞춰 Binomial / Gaussian 커널을 완전히 새로 생성합니다. 이 때, GetBinomial1D(winW) 는 winW 크기에 해당하는 파스칼 삼각형의 행을 처음부터 다시 계산하기 때문에, 원래 커널과 분포 형태 자체가 달라져 결과에 왜곡이 발생합니다.
GetBinomial1D(winW) 로 축소된 새 커널을 생성하지만, 이 커널은 1D 가중치 벡터 하나입니다. 중심 가중치 비율이 위치마다 달라져도, 1D 에서 "커널 형태 왜곡" 이란 경계에서 더 좁은 Binomial 분포가 적용된다는 것을 의미할 뿐입니다. 여기서는 커널 크기 자체가 줄어들어 분모도 함께 줄어들므로 평균값이 낮아지거나 밝기가 달라지는 현상은 발생하지 않습니다.winW 와 winH 가 달라지면 외적의 결과로 생성되는 2D 커널의 중심 가중치 비율이 달라져, 경계 픽셀이 내부 픽셀과 다른 강도로 블러됩니다. 즉 1D 의 단순 크기 축소가 2D 외적을 통해 중심 가중치 비율의 불일치로 증폭되는 것이 왜곡의 본질입니다.
Convolve1D_X/Y 는 AdaptiveMask · ValidOnly 와 동일한 경로 (GetAsymmetricSg(left, right, polyOrder)) 를 사용합니다. 따라서 SG 에서는 세 가지 모드의 결과가 동일합니다.
원래 크기 (2r + 1)² 윈도우를 그대로 순회합니다. 각 픽셀에서 unsigned 비교 한 번으로 범위 밖 여부를 검사해 건너뛰고, 유효 픽셀의 가중치만 denom 에 누적합니다. 핵심은 커널 자체를 바꾸지 않는다는 점입니다. 원래 (2r + 1) 크기 Binomial / Gaussian 커널의 가중치 비율이 그대로 유지되어, 내부 픽셀과 경계 픽셀이 동일한 커널 형태로 처리됩니다.
"AdaptiveMask 대신 커널 창을 항상 대칭으로 줄이면 완벽한 Zero-phase 를 달성할 수 있지 않을까?" 라는 질문에 대한 심층 분석입니다. 결론부터 말하면 1D 에서는 합리적 절충이 될 수 있지만, 2D 에서는 구조적으로 실용성이 붕괴됩니다.
현재 AdaptiveMask 는 경계에서 커널 창 크기를 그대로 유지하되, 범위 밖 샘플을 건너뛰고 재정규화합니다. 이 때 창이 비대칭으로 절단됩니다. 대칭적 축소는 대신 창을 항상 현재 픽셀을 중심으로 좌우 / 상하 대칭되도록 줄이는 방식입니다.
// x = 1, r = 3 → wx = -3, -2, -1, 0, 1, 2, 3 // skip skip skip ✓ ✓ ✓ ✓ → 4개 유효 for (int wx = -r; wx <= r; wx++) { int nx = x + wx; if ((uint) nx >= (uint) width) continue; // OOB 건너뜀 double w = coeff1D[wx + r]; // 전체 커널 가중치 유지 // ... }
// x = 1, r = 3 → symR = min(1, width - 2) = 1 // wx = -1, 0, 1 → 3 개 유효 (항상 대칭) int symR = Math.Min(Math.Min(r, x), width - 1 - x); for (int wx = -symR; wx <= symR; wx++) { double w = coeff1D[wx + r]; // 중심부 가중치만 int nx = x + wx; // ... }
대칭적 축소는 정의상 항상 완벽한 zero-phase 입니다. 커널 창이 [-symR, ..., 0, ..., +symR] 로 항상 중심 대칭이고, 원래 커널 coeff1D 가 대칭 (coeff1D[r - k] = coeff1D[r + k]) 이므로 :
현재 AdaptiveMask 의 잔여 CoM 편이 (x = 0, r = 3 에서 약 ~0.71 px) 가 완전히 제거됩니다. 이 편이는 비대칭 절단 때문에 발생합니다.
픽셀 스트립을 클릭 · 드래그해 위치 (x) 를 바꾸며 두 가지 방식의 커널 창이 실시간으로 어떻게 변화하는지 확인하세요.
idx < 0 → −idx − 1 (half-sample symmetric, 경계 픽셀 중복 포함)| 방식 | Zero-phase | 데이터 활용 | 경계 스무딩 | 아티팩트 |
|---|---|---|---|---|
| Symmetric | ✅ 완벽 | ✅ 100% (미러) | ✅ 균일 | 없음 |
| Replicate | ✅ 완벽 | ✅ 100% (복제) | ✅ 균일 | 없음 |
| AdaptiveMask현재 | ⚠️ ~0.71 px | ✅ 높음 | ⚠️ 약간 약화 | 거의 없음 |
| 대칭 축소적용 후 보류 | ✅ 완벽 | ❌ 제곱 손실 (2D) | ❌ 급격한 구배 | 스무딩 램프 |
| Adaptive | ❌ ~1.5 px | ✅ 높음 | ⚠️ 약간 약화 | 위상 편이 |
x = 2, r = 5 일 때 :symR = min(2, 5) = 2 → 5 개 샘플 (45% 잔여)x = 2, y = 2, r = 5 일 때 :2D 분리 가능 (separable) 커널은 X 축 1D 커널과 Y 축 1D 커널의 외적 (outer product) 으로 구성됩니다. 대칭 축소를 X 축에 적용하면 X 축 샘플이 줄고, Y 축에도 적용하면 Y 축 샘플도 줄어듭니다. 2D 유효 샘플 수는 두 축의 곱이므로, 각 축의 잔여율이 서로 곱해져 제곱 감소가 발생합니다.
(x = 1, y = 1), r = 5 일 때 :
(x = 0, y = 0), r = 5 → 대칭 축소 : 1 / 121 (0.8%) = 사실상 스무딩 없음. AdaptiveMask : 36 / 121 (30%) = 의미 있는 스무딩 유지.
| 위치 (x 또는 x = y) | 1D symR | 1D 잔여율 | 2D 잔여율 (대칭 축소) |
2D 잔여율 (AdaptiveMask) |
|---|---|---|---|---|
| 0 | 0 | 1 / 11 (9%) | 1 / 121 (0.8%) | 36 / 121 (30%) |
| 1 | 1 | 3 / 11 (27%) | 9 / 121 (7%) | 49 / 121 (40%) |
| 2 | 2 | 5 / 11 (45%) | 25 / 121 (21%) | 64 / 121 (53%) |
| 3 | 3 | 7 / 11 (64%) | 49 / 121 (40%) | 81 / 121 (67%) |
| 4 | 4 | 9 / 11 (82%) | 81 / 121 (67%) | 100 / 121 (83%) |
| ≥ 5 (내부) | 5 | 11 / 11 (100%) | 121 / 121 (100%) | 121 / 121 (100%) |
| 전략 | Zero-ph. | 데이터 | 실용성 |
|---|---|---|---|
| Symmetric 확장 | ✅ | ✅ | ✅ |
| 대칭 축소 | ✅ | ⚠️ 보통 | ✅ |
| AdaptiveMask | ⚠️ | ✅ | ✅ |
| 전략 | Zero-ph. | 데이터 | 실용성 |
|---|---|---|---|
| Symmetric 확장 | ✅ | ✅ | ✅ |
| 대칭 축소 | ✅ | ❌ 제곱 손실 | ❌ |
| AdaptiveMask | ⚠️ | ✅ | ✅ |
GetIndex1D() 함수가 범위 밖 인덱스에 대해 −1 을 반환하고, 호출 측에서 nx < 0 를 확인해 해당 픽셀을 완전히 건너뜁니다. count 또는 denom 이 유효 픽셀만큼만 줄어들어, 분자와 분모의 비율이 유지됩니다. 따라서 AdaptiveMask 와 최종 출력이 동일합니다.
Convolve1D_X / Y 에서 ValidOnly · Adaptive · AdaptiveMask 세 가지 모드는 모두 동일한 경계 분기를 탑니다. 좌우 가용 범위 left = Math.Min(r, x), right = Math.Min(r, width − 1 − x) 를 계산하고, GetAsymmetricSg(left, right, localPoly) 로 비대칭 SG 계수를 생성합니다. 동일한 (left, right, polyOrder) 조합은 Dictionary 에 캐싱됩니다.
| 구분 | Adaptive | AdaptiveMask | ValidOnly |
|---|---|---|---|
| 경계 커널 (SG 이외의 모든 필터) | 재계산 GetBinomial1D(winW) |
전체 커널 보존 GetBinomial1D(2r + 1) |
전체 커널 보존 GetBinomial1D(2r + 1) |
| OOB 처리 | 윈도우 자체를 줄임 (GetIndex1D 미사용) |
건너뜀 + 재정규화 (uint) nx ≥ width → continue |
건너뜀 + 재정규화 GetIndex1D() → −1 → continue |
| SG 이외의 모든 필터 결과 | 다름 (새 커널 적용) | 동일 | 동일 |
| SG 결과 | 비대칭 재정규화 GetAsymmetricSg(l, r, p) |
비대칭 재정규화 GetAsymmetricSg(l, r, p) |
비대칭 재정규화 GetAsymmetricSg(l, r, p) |
Convolve1D_X/Y 에서는 Adaptive · AdaptiveMask · ValidOnly 세 모드 모두 동일한 코드 경로 (GetAsymmetricSg(left, right, polyOrder)) 를 사용하므로 SG 에서는 결과가 동일합니다.| Mode | 경계 왜곡 | 속도 | 가중치 재계산 | 권장 용도 |
|---|---|---|---|---|
| Symmetric | 거의 없음 | 빠름 | 불필요 | 일반 용도 (기본값) |
| Replicate | 약간 | 빠름 | 불필요 | 경계 밝기 평탄화 |
| Adaptive | 있음 (2D 커널 형태 변형) | 빠름 | winW / H 마다 새 커널 재생성 | 1D 신호 처리 (2D 이미지 비권장) |
| AdaptiveMask | 없음 | 보통 | 불필요 (원래 커널 유지) | 2D 이미지 경계 · 마스크 영역 처리 |
| ZeroPad | 어두워짐 | 빠름 | 불필요 | 주파수 / 신호 처리 |
| ValidOnly | 없음 | 보통 | SG : 비대칭 계수 SG 이외의 모든 필터 : AdaptiveMask 와 동일 | SG 경계 처리 · 미분 계수 추출 |
동일한 BoundaryMode 라도 각 필터가 경계를 처리하는 방식은 다릅니다. 특히 Adaptive 모드에서 가중치 재계산 방식이 필터마다 큰 차이가 있습니다.
| 필터 | Symmetric / Replicate | Adaptive | AdaptiveMask | ValidOnly / ZeroPad |
|---|---|---|---|---|
| Rectangular | GetIndex1D → 균일 합산 count = (2r + 1)² 고정 |
윈도우 축소 count = winW × winH |
uint 범위 검사 count 감소 |
ZeroPad : 값 0 포함, count++ → count 고정 (= (2r + 1)²)ValidOnly : 범위 밖 건너 뜀 → count 감소 |
| Binomial Avg | GetIndex1D → 이항 가중합 denom = Σw 전체 |
GetBinomial1D(winW) 축소 계수 재생성 (캐싱) |
uint 범위 검사 denom = 유효 w 만 |
ZeroPad : denom += w 포함 → denom 고정ValidOnly : 범위 밖 건너 뜀 → denom 변동 |
| Binom. Median | GetIndex1D → Bucket 전체 가중치 |
GetBinomial1D(winW) 축소 계수 + Bucket |
uint 검사 → Bucket 유효 샘플만 |
ZeroPad : 값 0 + 가중치 Bucket 추가 → count 고정 ValidOnly : 범위 밖 건너 뜀 → count 감소 |
| Gauss. Median | GetIndex1D → Bucket 전체 가중치 |
ComputeGaussian1D(winW, σ) σ 도 비례 축소 (캐싱 없음) |
uint 검사 → Bucket 유효 샘플만 |
ZeroPad : 값 0 + 가중치 Bucket 추가 → count 고정 ValidOnly : 범위 밖 건너 뜀 → count 감소 |
| Gaussian Avg | GetIndex1D → 가중합 denom = Σw 전체 |
ComputeGaussian1D(winW, σ) σ 비례 축소 (캐싱 없음) |
uint 검사 denom = 유효 w 만 |
ZeroPad : denom += w 포함 → denom 고정ValidOnly : 범위 밖 건너 뜀 → denom 변동 |
| Savitzky-Golay | GetIndex1D → 계수 내적 denom ≈ 1 (합 = 1 정규화) |
GetAsymmetricSg(l, r, p) 비대칭 QR 분해 (캐싱) |
비대칭 SG 계수 캐싱 |
비대칭 SG 계수 캐싱 |
count++ (또는 denom += w) 처리하므로 분모가 항상 (2r+1)² 고정입니다. 범위 밖 값 0 이 평균에 포함되어 경계가 어두워집니다. ValidOnly / AdaptiveMask 는 범위 밖을 완전히 건너뛰므로 분모가 유효 샘플 수만큼 줄어들고 밝기 왜곡이 없습니다.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) // Smoothing 캐시 : 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)