📷

画像処理100本ノックに挑戦|ガウシアンフィルタ(009/100)

2025/01/10に公開

これはなに?

画像処理100本ノックを、TypeScriptとlibvipsで挑戦してみる記事の9本目です。

前回

https://zenn.dev/nyagato_00/articles/0a008e6bd4439b

実装

お題

ガウシアンフィルタ(3x3、標準偏差1.3)を実装し、imori_noise.jpgのノイズを除去せよ。

ガウシアンフィルタとは画像の平滑化(滑らかにする)を行うフィルタの一種であり、ノイズ除去にも使われる。

ノイズ除去には他にも、メディアンフィルタ(Q.10)、平滑化フィルタ(Q.11)、LoGフィルタ(Q.19)などがある。

ガウシアンフィルタは注目画素の周辺画素を、ガウス分布による重み付けで平滑化し、次式で定義される。 このような重みはカーネルやフィルタと呼ばれる。

ただし、画像の端はこのままではフィルタリングできないため、画素が足りない部分は0で埋める。これを0パディングと呼ぶ。 かつ、重みは正規化する。(sum g = 1)

重みはガウス分布から次式になる。

重み g(x,y,s) = 1/ (2 * pi * sigma * sigma) * exp( - (x^2 + y^2) / (2*s^2))
標準偏差s = 1.3による8近傍ガウシアンフィルタは
            1 2 1
K =  1/16 [ 2 4 2 ]
            1 2 1

https://github.com/minido/Gasyori100knock-1/tree/master/Question_01_10#q9-ガウシアンフィルタ

Coding

import sharp from 'sharp';

export async function gaussianFilter(
  inputPath: string, 
  outputPath: string, 
  kernelSize: number = 3, 
  sigma: number = 1.3
): Promise<void> {
  try {
    const image = await sharp(inputPath)
      .raw()
      .toBuffer({ resolveWithObject: true });

    const { data, info } = image;
    const { width, height, channels } = info;

    // パディングサイズを計算
    const pad = Math.floor(kernelSize / 2);

    // パディング付きの配列を作成
    const paddedHeight = height + 2 * pad;
    const paddedWidth = width + 2 * pad;
    const temp = new Float32Array(paddedHeight * paddedWidth * channels);

    // ゼロパディング
    for (let y = 0; y < height; y++) {
      for (let x = 0; x < width; x++) {
        for (let c = 0; c < channels; c++) {
          const srcPos = (y * width + x) * channels + c;
          const destPos = ((y + pad) * paddedWidth + (x + pad)) * channels + c;
          temp[destPos] = data[srcPos];
        }
      }
    }

    // カーネルを準備(3x3の場合、1/16で正規化された重み付け)
    const kernel = new Float32Array(kernelSize * kernelSize);
    if (kernelSize === 3) {
      // 3x3カーネルの場合、お手本の値を直接使用
      kernel.set([
        1/16, 2/16, 1/16,
        2/16, 4/16, 2/16,
        1/16, 2/16, 1/16
      ]);
    } else {
      // その他のサイズの場合は計算
      let sum = 0;
      for (let y = -pad; y <= pad; y++) {
        for (let x = -pad; x <= pad; x++) {
          const exp = -(x * x + y * y) / (2 * sigma * sigma);
          const value = Math.exp(exp);
          kernel[(y + pad) * kernelSize + (x + pad)] = value;
          sum += value;
        }
      }
      // カーネルの正規化
      for (let i = 0; i < kernel.length; i++) {
        kernel[i] /= sum;
      }
    }

    // 結果用の配列を作成
    const result = Buffer.alloc(width * height * channels);

    // フィルタリング
    for (let y = 0; y < height; y++) {
      for (let x = 0; x < width; x++) {
        for (let c = 0; c < channels; c++) {
          let sum = 0;
          
          // カーネルとの畳み込み
          for (let ky = 0; ky < kernelSize; ky++) {
            for (let kx = 0; kx < kernelSize; kx++) {
              const py = y + ky;
              const px = x + kx;
              const sourcePos = (py * paddedWidth + px) * channels + c;
              sum += temp[sourcePos] * kernel[ky * kernelSize + kx];
            }
          }

          // 結果を保存
          const destPos = (y * width + x) * channels + c;
          result[destPos] = Math.min(255, Math.max(0, Math.round(sum)));
        }
      }
    }

    // 結果を保存
    await sharp(result, {
      raw: {
        width,
        height,
        channels
      }
    })
    .toFile(outputPath);

  } catch (error) {
    console.error('ガウシアンフィルタの適用中にエラーが発生しました:', error);
    throw error;
  }
}

Test

import { existsSync, unlinkSync } from 'fs';
import { join } from 'path';
import sharp from 'sharp';
import { gaussianFilter } from './imageProcessor';

describe('Gaussian Filter Tests', () => {
  const testInputPath = join(__dirname, '../test-images/test_noise.jpg');
  const testOutputPath = join(__dirname, '../test-images/test-gaussian.png');

  afterEach(() => {
    if (existsSync(testOutputPath)) {
      unlinkSync(testOutputPath);
    }
  });

  test('should successfully apply gaussian filter', async () => {
    await expect(gaussianFilter(testInputPath, testOutputPath))
      .resolves.not.toThrow();
    expect(existsSync(testOutputPath)).toBe(true);
  });

  test('should maintain image dimensions', async () => {
    await gaussianFilter(testInputPath, testOutputPath);
    
    const inputMetadata = await sharp(testInputPath).metadata();
    const outputMetadata = await sharp(testOutputPath).metadata();

    expect(outputMetadata.width).toBe(inputMetadata.width);
    expect(outputMetadata.height).toBe(inputMetadata.height);
  });

  test('should reduce noise', async () => {
    await gaussianFilter(testInputPath, testOutputPath);
    
    const inputImage = await sharp(testInputPath)
      .raw()
      .toBuffer({ resolveWithObject: true });
    
    const outputImage = await sharp(testOutputPath)
      .raw()
      .toBuffer({ resolveWithObject: true });

    // ノイズ低減を確認(標準偏差の比較)
    const calculateStdDev = (data: Buffer) => {
      const mean = data.reduce((sum, val) => sum + val, 0) / data.length;
      const variance = data.reduce((sum, val) => sum + (val - mean) ** 2, 0) / data.length;
      return Math.sqrt(variance);
    };

    const inputStdDev = calculateStdDev(inputImage.data);
    const outputStdDev = calculateStdDev(outputImage.data);

    // フィルタ適用後の標準偏差が小さくなっているはず
    expect(outputStdDev).toBeLessThan(inputStdDev);
  });
});

結果

入力 結果

Discussion