📷

画像処理100本ノックに挑戦|平滑化フィルタ(011/100)

2025/01/12に公開

これはなに?

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

前回

https://zenn.dev/nyagato_00/articles/136ffd6ed7391a

実装

お題

平滑化フィルタ(3x3)を実装せよ。

平滑化フィルタはフィルタ内の画素の平均値を出力するフィルタである。

https://github.com/minido/Gasyori100knock-1/tree/master/Question_11_20#q11-平滑化フィルタ

Coding

import sharp from 'sharp';

export async function meanFilter(
  inputPath: string, 
  outputPath: string, 
  kernelSize: number = 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 < paddedHeight; y++) {
      for (let x = 0; x < paddedWidth; x++) {
        for (let c = 0; c < channels; c++) {
          const destPos = (y * paddedWidth + x) * channels + c;
          if (y >= pad && y < paddedHeight - pad && 
              x >= pad && x < paddedWidth - pad) {
            const srcY = y - pad;
            const srcX = x - pad;
            const srcPos = (srcY * width + srcX) * channels + c;
            temp[destPos] = data[srcPos];
          } else {
            temp[destPos] = 0;
          }
        }
      }
    }

    // 結果用の配列を作成
    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;
          const totalPixels = kernelSize * kernelSize;
          
          for (let ky = 0; ky < kernelSize; ky++) {
            for (let kx = 0; kx < kernelSize; kx++) {
              const py = y + ky;
              const px = x + kx;
              const pos = (py * paddedWidth + px) * channels + c;
              sum += temp[pos];
            }
          }
          
          // 平均値を計算して保存
          const destPos = (y * width + x) * channels + c;
          result[destPos] = Math.round(sum / totalPixels);
        }
      }
    }

    // 結果を保存
    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 { meanFilter } from './imageProcessor';

describe('Mean Filter Tests', () => {
  const testInputPath = join(__dirname, '../test-images/test.jpeg');
  const testOutputPath = join(__dirname, '../test-images/test-mean.png');

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

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

  test('should maintain image dimensions', async () => {
    await meanFilter(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 produce smoothed output', async () => {
    // 元画像と平滑化後の画像の標準偏差を比較
    const inputImage = await sharp(testInputPath)
      .raw()
      .toBuffer({ resolveWithObject: true });

    await meanFilter(testInputPath, testOutputPath, 3);
    
    const outputImage = await sharp(testOutputPath)
      .raw()
      .toBuffer({ resolveWithObject: true });

    // 標準偏差を計算する関数
    const calculateStdDev = (data: Buffer | Uint8Array) => {
      const mean = Array.from(data).reduce((sum, val) => sum + val, 0) / data.length;
      return Math.sqrt(
        Array.from(data)
          .reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / data.length
      );
    };

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

    // 平滑化により標準偏差が小さくなることを確認
    expect(outputStdDev).toBeLessThan(inputStdDev);
  });

  test('should average pixel values correctly', async () => {
    const kernelSize = 3;
    await meanFilter(testInputPath, testOutputPath, kernelSize);
    
    const outputImage = await sharp(testOutputPath)
      .raw()
      .toBuffer({ resolveWithObject: true });

    // サンプルの領域をチェック
    const { data, info } = outputImage;
    const { width, channels } = info;
    
    // 中心付近のピクセルを確認
    const centerX = Math.floor(width / 2);
    const centerY = Math.floor(width / 2);
    const centerPos = (centerY * width + centerX) * channels;

    // 極端な値がないことを確認
    for (let c = 0; c < channels; c++) {
      expect(data[centerPos + c]).toBeGreaterThanOrEqual(0);
      expect(data[centerPos + c]).toBeLessThanOrEqual(255);
    }
  });
});

結果

入力 結果

Discussion