📷

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

2025/01/17に公開

これはなに?

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

前回

https://zenn.dev/nyagato_00/articles/e1c99379da9685

実装

お題

Prewittフィルタ(3x3)を実装せよ。

Prewittフィルタはエッジ抽出フィルタの一種であり、次式で定義される。

    (a)縦方向          (b)横方向
      -1 -1 -1          -1 0 1
K = [  0  0  0 ]  K = [ -1 0 1 ]
       1  1  1          -1 0 1

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

Coding

import sharp from 'sharp';

export async function prewittFilter(
  inputPath: string, 
  outputVerticalPath: string,
  outputHorizontalPath: 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 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];
        }
      }
    }

    // Prewittカーネルの定義
    const verticalKernel = [
      [-1, -1, -1],
      [0, 0, 0],
      [1, 1, 1]
    ];
    
    const horizontalKernel = [
      [-1, 0, 1],
      [-1, 0, 1],
      [-1, 0, 1]
    ];

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

    // フィルタリング
    for (let y = 0; y < height; y++) {
      for (let x = 0; x < width; x++) {
        let sumVertical = 0;
        let sumHorizontal = 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];
            
            // 縦方向と横方向の畳み込み
            sumVertical += pixel * verticalKernel[ky][kx];
            sumHorizontal += pixel * horizontalKernel[ky][kx];
          }
        }

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

    // 結果を保存(縦方向)
    await sharp(verticalResult, {
      raw: {
        width,
        height,
        channels: 1
      }
    })
    .toFile(outputVerticalPath);

    // 結果を保存(横方向)
    await sharp(horizontalResult, {
      raw: {
        width,
        height,
        channels: 1
      }
    })
    .toFile(outputHorizontalPath);

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

Test

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

describe('Prewitt Filter Tests', () => {
  const testInputPath = join(__dirname, '../test-images/test.jpeg');
  const testOutputVerticalPath = join(__dirname, '../test-images/test-prewitt-v.jpg');
  const testOutputHorizontalPath = join(__dirname, '../test-images/test-prewitt-h.jpg');

  // 各テスト後に生成された画像を削除
  afterEach(() => {
    [testOutputVerticalPath, testOutputHorizontalPath].forEach(path => {
      if (existsSync(path)) {
        unlinkSync(path);
      }
    });
  });

  test('should successfully apply Prewitt filter', async () => {
    await expect(prewittFilter(
      testInputPath, 
      testOutputVerticalPath,
      testOutputHorizontalPath
    )).resolves.not.toThrow();

    expect(existsSync(testOutputVerticalPath)).toBe(true);
    expect(existsSync(testOutputHorizontalPath)).toBe(true);
  });

  test('should maintain image dimensions', async () => {
    await prewittFilter(
      testInputPath, 
      testOutputVerticalPath,
      testOutputHorizontalPath
    );
    
    const inputMetadata = await sharp(testInputPath).metadata();
    const verticalMetadata = await sharp(testOutputVerticalPath).metadata();
    const horizontalMetadata = await sharp(testOutputHorizontalPath).metadata();

    expect(verticalMetadata.width).toBe(inputMetadata.width);
    expect(verticalMetadata.height).toBe(inputMetadata.height);
    expect(horizontalMetadata.width).toBe(inputMetadata.width);
    expect(horizontalMetadata.height).toBe(inputMetadata.height);
  });

  test('should detect edges', async () => {
    await prewittFilter(
      testInputPath, 
      testOutputVerticalPath,
      testOutputHorizontalPath
    );
    
    const verticalImage = await sharp(testOutputVerticalPath)
      .raw()
      .toBuffer({ resolveWithObject: true });
    
    const horizontalImage = await sharp(testOutputHorizontalPath)
      .raw()
      .toBuffer({ resolveWithObject: true });

    let hasEdgesVertical = false;
    let hasEdgesHorizontal = false;
    let hasNonEdges = false;

    // エッジと非エッジ領域の存在を確認
    for (let i = 0; i < verticalImage.data.length; i++) {
      const verticalValue = verticalImage.data[i];
      const horizontalValue = horizontalImage.data[i];

      if (verticalValue > 30) hasEdgesVertical = true;
      if (horizontalValue > 30) hasEdgesHorizontal = true;
      if (verticalValue < 10 && horizontalValue < 10) hasNonEdges = true;

      if (hasEdgesVertical && hasEdgesHorizontal && hasNonEdges) break;
    }

    expect(hasEdgesVertical).toBe(true);
    expect(hasEdgesHorizontal).toBe(true);
    expect(hasNonEdges).toBe(true);
  });

  test('should have valid pixel values', async () => {
    await prewittFilter(
      testInputPath, 
      testOutputVerticalPath,
      testOutputHorizontalPath
    );
    
    const verticalImage = await sharp(testOutputVerticalPath)
      .raw()
      .toBuffer({ resolveWithObject: true });
    
    const horizontalImage = await sharp(testOutputHorizontalPath)
      .raw()
      .toBuffer({ resolveWithObject: true });

    // すべてのピクセル値が有効な範囲(0-255)内にあることを確認
    const isValidValue = (value: number) => value >= 0 && value <= 255;
    
    expect(Array.from(verticalImage.data).every(isValidValue)).toBe(true);
    expect(Array.from(horizontalImage.data).every(isValidValue)).toBe(true);
  });
});

結果

入力 出力・縦方向 出力・横方向

Discussion