📷

画像処理100本ノックに挑戦|モーションフィルタ(012/100)

2025/01/13に公開

これはなに?

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

前回

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

実装

お題

モーションフィルタ(3x3)を実装せよ。

モーションフィルタとは対角方向の平均値を取るフィルタであり、次式で定義される。

  1/3  0   0
[  0  1/3  0 ]
   0   0  1/3

https://github.com/minido/Gasyori100knock-1/tree/master/Question_11_20#q12-モーションフィルタ

Coding

import sharp from 'sharp';

export async function motionFilter(
  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 kernel = new Float32Array(kernelSize * kernelSize);
    for (let i = 0; i < kernelSize; i++) {
      kernel[i * kernelSize + i] = 1.0 / kernelSize;  // 対角要素に1/kernelSizeを設定
    }

    // パディングサイズを計算
    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;
          
          // カーネルとの畳み込み
          for (let ky = 0; ky < kernelSize; ky++) {
            for (let kx = 0; kx < kernelSize; kx++) {
              const py = y + ky;
              const px = x + kx;
              const kernelValue = kernel[ky * kernelSize + kx];
              const pos = (py * paddedWidth + px) * channels + c;
              sum += temp[pos] * kernelValue;
            }
          }
          
          // 結果を保存
          const destPos = (y * width + x) * channels + c;
          result[destPos] = Math.round(sum);
        }
      }
    }

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

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

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

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

  test('should maintain image dimensions', async () => {
    await motionFilter(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 apply diagonal blur', async () => {
    const kernelSize = 3;
    await motionFilter(testInputPath, testOutputPath, kernelSize);
    
    const outputImage = await sharp(testOutputPath)
      .raw()
      .toBuffer({ resolveWithObject: true });

    // カーネルサイズ分の領域をチェック
    const { data, info } = outputImage;
    const { channels } = info;

    // ピクセル値が有効な範囲内にあることを確認
    for (let i = 0; i < data.length; i += channels) {
      for (let c = 0; c < channels; c++) {
        expect(data[i + c]).toBeGreaterThanOrEqual(0);
        expect(data[i + c]).toBeLessThanOrEqual(255);
      }
    }
  });
});

結果

入力 結果

Discussion