📷

画像処理100本ノックに挑戦|メディアンフィルタ(010/100)

2025/01/11に公開

これはなに?

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

前回

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

実装

お題

メディアンフィルタ(3x3)を実装し、imori_noise.jpgのノイズを除去せよ。

メディアンフィルタとは画像の平滑化を行うフィルタの一種である。

これは注目画素の3x3の領域内の、メディアン値(中央値)を出力するフィルタである。 これもゼロパディングせよ。

https://github.com/minido/Gasyori100knock-1/tree/master/Question_01_10#q10-メディアンフィルタ

Coding

import sharp from 'sharp';

function getMedian(values: number[]): number {
  const sorted = values.sort((a, b) => a - b);
  const middle = Math.floor(sorted.length / 2);
  return sorted[middle];
}

export async function medianFilter(
  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++) {
          // カーネル領域の値を収集
          const values: number[] = [];
          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;
              values.push(temp[pos]);
            }
          }
          
          // メディアン値を計算して保存
          const destPos = (y * width + x) * channels + c;
          result[destPos] = getMedian(values);
        }
      }
    }

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

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

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

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

  test('should maintain image dimensions', async () => {
    await medianFilter(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 () => {
    const kernelSize = 3;
    
    // 入力画像と出力画像を取得
    const inputImage = await sharp(testInputPath)
      .raw()
      .toBuffer({ resolveWithObject: true });

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

    // 入力画像と出力画像の標準偏差を計算して比較
    const getStdDev = (data: Buffer | Uint8Array) => {
      const mean = Array.from(data).reduce((sum, val) => sum + val, 0) / data.length;
      const variance = Array.from(data)
        .reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / data.length;
      return Math.sqrt(variance);
    };

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

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

結果

入力 結果

Discussion