📷

画像処理100本ノックに挑戦|MAX-MINフィルタ(013/100)

2025/01/14に公開

これはなに?

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

前回

https://zenn.dev/nyagato_00/articles/16fccb35dc2973

実装

お題

MAX-MINフィルタ(3x3)を実装せよ。

MAX-MINフィルタとはフィルタ内の画素の最大値と最小値の差を出力するフィルタであり、エッジ検出のフィルタの一つである。 エッジ検出とは画像内の線を検出るすることであり、このような画像内の情報を抜き出す操作を特徴抽出と呼ぶ。 エッジ検出では多くの場合、グレースケール画像に対してフィルタリングを行う。

https://github.com/minido/Gasyori100knock-1/tree/master/Question_11_20#q13-max-minフィルタ

Coding

import sharp from 'sharp';

export async function maxMinFilter(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 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);

    // ゼロパディング(元のPythonコードと同じ方法)
    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];
        }
      }
    }

    // フィルタリング(元のPythonコードと同じ方法)
    const result = Buffer.alloc(width * height);
    for (let y = 0; y < height; y++) {
      for (let x = 0; x < width; x++) {
        // カーネル領域の最大値と最小値を取得
        let values: number[] = [];
        for (let ky = 0; ky < kernelSize; ky++) {
          for (let kx = 0; kx < kernelSize; kx++) {
            const py = y + ky;
            const px = x + kx;
            values.push(tempData[py * paddedWidth + px]);
          }
        }
        
        // 最大値と最小値の差を計算
        const maxVal = Math.max(...values);
        const minVal = Math.min(...values);
        const pos = y * width + x;
        result[pos] = Math.min(255, Math.max(0, maxVal - minVal));
      }
    }

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

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

Test

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

describe('MAX-MIN Filter Tests', () => {
  const testInputPath = join(__dirname, '../test-images/test.jpeg');
  const testOutputPath = join(__dirname, '../test-images/test-maxmin.jpg');

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

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

  test('should detect edges', async () => {
    await maxMinFilter(testInputPath, testOutputPath);
    
    const outputImage = await sharp(testOutputPath)
      .raw()
      .toBuffer({ resolveWithObject: true });

    // エッジ検出の結果、画像内に十分な変化があることを確認
    const { data } = outputImage;
    let hasEdges = false;
    let hasNonEdges = false;
    
    for (let i = 0; i < data.length; i++) {
      if (data[i] > 30) {  // エッジとして検出された部分
        hasEdges = true;
      }
      if (data[i] < 10) {  // エッジとして検出されなかった部分
        hasNonEdges = true;
      }
      if (hasEdges && hasNonEdges) {
        break;  // 両方見つかったら終了
      }
    }
    
    // エッジと非エッジの両方が存在することを確認
    expect(hasEdges).toBe(true);
    expect(hasNonEdges).toBe(true);
  });

  test('should maintain image dimensions', async () => {
    await maxMinFilter(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 have valid pixel values', async () => {
    await maxMinFilter(testInputPath, testOutputPath);
    
    const outputImage = await sharp(testOutputPath)
      .raw()
      .toBuffer({ resolveWithObject: true });

    // すべてのピクセル値が0-255の範囲内にあることを確認
    const { data } = outputImage;
    const isValidPixelValue = Array.from(data).every(value => 
      value >= 0 && value <= 255
    );
    
    expect(isValidPixelValue).toBe(true);
  });
});

結果

入力 結果

Discussion