📷

画像処理100本ノックに挑戦|LoGフィルタ(019/100)

2025/01/20に公開

これはなに?

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

前回

https://zenn.dev/nyagato_00/articles/266a552899dcbc

実装

お題

LoGフィルタ(sigma=3、カーネルサイズ=5)を実装し、imori_noise.jpgのエッジを検出せよ。

LoGフィルタとはLaplacian of Gaussianであり、ガウシアンフィルタで画像を平滑化した後にラプラシアンフィルタで輪郭を取り出すフィルタである。

Laplcianフィルタは二次微分をとるのでノイズが強調されるのを防ぐために、予めGaussianフィルタでノイズを抑える。

LoGフィルタは次式で定義される。

LoG(x,y) = (x^2 + y^2 - sigma^2) / (2 * pi * sigma^6) * exp(-(x^2+y^2) / (2*sigma^2))

https://github.com/minido/Gasyori100knock-1/tree/master/Question_11_20#q19-logフィルタ

Coding

import sharp from 'sharp';

export async function logFilter(
  inputPath: string, 
  outputPath: string, 
  kernelSize: number = 5,
  sigma: number = 3
): Promise<void> {
  try {
    // まずグレースケールに変換
    const image = await sharp(inputPath)
      .raw()
      .toBuffer({ resolveWithObject: true });

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

    // グレースケール変換
    const grayData = Buffer.alloc(width * height);
    for (let i = 0; i < data.length; i += channels) {
      const b = data[i];
      const g = data[i + 1];
      const r = data[i + 2];
      const gray = Math.round(0.2126 * r + 0.7152 * g + 0.0722 * b);
      grayData[i / channels] = gray;
    }

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

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

    // ゼロパディング
    for (let y = 0; y < paddedHeight; y++) {
      for (let x = 0; x < paddedWidth; x++) {
        const destPos = y * paddedWidth + x;
        if (y >= pad && y < paddedHeight - pad && 
            x >= pad && x < paddedWidth - pad) {
          const srcY = y - pad;
          const srcX = x - pad;
          const srcPos = srcY * width + srcX;
          paddedData[destPos] = grayData[srcPos];
          tempData[destPos] = grayData[srcPos];
        }
      }
    }

    // LoGカーネルの作成(Pythonコードと同じ方法で)
    const kernel = new Float32Array(kernelSize * kernelSize);
    let kernelSum = 0;

    for (let y = -pad; y < -pad + kernelSize; y++) {
      for (let x = -pad; x < -pad + kernelSize; x++) {
        const r2 = x * x + y * y;
        const sigma2 = sigma * sigma;
        
        // LoG関数の計算
        const value = (r2 - sigma2) * Math.exp(-r2 / (2 * sigma2));
        const idx = (y + pad) * kernelSize + (x + pad);
        kernel[idx] = value;
        kernelSum += value;
      }
    }

    // Pythonコードと同じ正規化
    const normFactor = 1 / (2 * Math.PI * Math.pow(sigma, 6));
    for (let i = 0; i < kernel.length; i++) {
      kernel[i] *= normFactor;
    }
    
    // 合計で正規化
    kernelSum = kernel.reduce((sum, val) => sum + val, 0);
    for (let i = 0; i < kernel.length; i++) {
      kernel[i] /= kernelSum;
    }

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

    // フィルタリング
    for (let y = 0; y < height; y++) {
      for (let x = 0; x < width; x++) {
        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 pixel = tempData[py * paddedWidth + px];
            const kernelValue = kernel[ky * kernelSize + kx];
            sum += pixel * kernelValue;
          }
        }

        // 結果を保存(0-255の範囲にクリップ)
        const pos = y * width + x;
        result[pos] = Math.min(255, Math.max(0, sum));
      }
    }

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

    console.log('LoGフィルタ処理が完了しました');
  } catch (error) {
    console.error('画像処理中にエラーが発生しました:', error);
    throw error;
  }
}

Test

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

describe('LoG Filter Tests', () => {
  const testInputPath = join(__dirname, '../test-images/imori_noise.jpg');
  const testOutputPath = join(__dirname, '../test-images/test-log.jpg');

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

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

  test('should maintain image dimensions', async () => {
    await logFilter(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 detect edges and reduce noise', async () => {
    await logFilter(testInputPath, testOutputPath);
    
    const outputImage = await sharp(testOutputPath)
      .raw()
      .toBuffer({ resolveWithObject: true });

    // エッジ検出と同時にノイズ低減の効果を確認
    let hasEdges = false;
    let hasSmoothedRegions = false;

    // ヒストグラムのような分布を確認
    const histogram = new Array(256).fill(0);
    for (const pixel of outputImage.data) {
      histogram[pixel]++;
      if (pixel > 100) hasEdges = true;
      if (pixel < 50) hasSmoothedRegions = true;
    }

    expect(hasEdges).toBe(true);
    expect(hasSmoothedRegions).toBe(true);
  });

  test('should apply correct kernel size and sigma', async () => {
    const kernelSize = 5;
    const sigma = 3;
    
    await logFilter(testInputPath, testOutputPath, kernelSize, sigma);
    
    const outputImage = await sharp(testOutputPath)
      .raw()
      .toBuffer({ resolveWithObject: true });

    // カーネルサイズとシグマの効果を確認
    const { data } = outputImage;
    const isValidOutput = data.some(value => value > 0 && value < 255);
    expect(isValidOutput).toBe(true);
  });
});

結果

入力 出力

Discussion