📷

画像処理100本ノックに挑戦|大津の二値化(004/100)

2025/01/05に公開

これはなに?

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

前回

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

実装

お題

大津の二値化を実装せよ。 大津の二値化とは判別分析法と呼ばれ、二値化における分離の閾値を自動決定する手法である。 これはクラス内分散とクラス間分散の比から計算される。

https://github.com/minido/Gasyori100knock-1/tree/master/Question_01_10#q4-大津の二値化

Coding

import sharp from 'sharp';

export async function otsuBinarization(inputPath: string, outputPath: string): Promise<{ threshold: number }> {
  try {
    // まず画像をグレースケールに変換してピクセルデータを取得
    const image = await sharp(inputPath)
      .grayscale()
      .raw()
      .toBuffer({ resolveWithObject: true });

    const { data } = image;

    // ヒストグラムの作成(0-255の出現頻度)
    const histogram = new Array(256).fill(0);
    for (const pixel of data) {
      histogram[pixel]++;
    }

    // 総ピクセル数
    const totalPixels = data.length;

    let maxVariance = 0;
    let optimalThreshold = 0;

    // すべての可能な閾値について分離度を計算
    for (let threshold = 0; threshold < 256; threshold++) {
      // クラス1(閾値未満)の画素数と平均
      let w1 = 0;
      let sum1 = 0;
      for (let i = 0; i < threshold; i++) {
        w1 += histogram[i];
        sum1 += i * histogram[i];
      }

      // クラス2(閾値以上)の画素数と平均
      let w2 = totalPixels - w1;
      if (w1 === 0 || w2 === 0) continue;

      // クラス1の平均
      const mean1 = sum1 / w1;

      // 全体の平均を計算
      let totalSum = 0;
      for (let i = 0; i < 256; i++) {
        totalSum += i * histogram[i];
      }
      const meanTotal = totalSum / totalPixels;

      // クラス間分散を計算
      const variance = Math.pow(meanTotal * w1 - sum1, 2) / (w1 * w2);

      // より良い閾値が見つかった場合は更新
      if (variance > maxVariance) {
        maxVariance = variance;
        optimalThreshold = threshold;
      }
    }

    // 計算された閾値で二値化を実行
    await sharp(inputPath)
      .grayscale()
      .threshold(optimalThreshold)
      .toFile(outputPath);

    console.log(`大津の二値化が完了しました(閾値: ${optimalThreshold}`);
    return { threshold: optimalThreshold };

  } catch (error) {
    console.error('画像処理中にエラーが発生しました:', error);
    throw error;
  }
}

Test

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

describe('Otsu Binarization Tests', () => {
  const testInputPath = join(__dirname, '../test-images/test.jpeg');
  const testOutputPath = join(__dirname, '../test-images/test-otsu.png');

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

  test('should successfully apply Otsu binarization', async () => {
    const result = await otsuBinarization(testInputPath, testOutputPath);
    expect(existsSync(testOutputPath)).toBe(true);
    expect(result.threshold).toBeGreaterThanOrEqual(0);
    expect(result.threshold).toBeLessThanOrEqual(255);
  });

  test('should output binary image with only black and white pixels', async () => {
    await otsuBinarization(testInputPath, testOutputPath);
    
    const outputImage = await sharp(testOutputPath)
      .raw()
      .toBuffer({ resolveWithObject: true });

    const uniqueValues = new Set(outputImage.data);
    expect(uniqueValues.size).toBeLessThanOrEqual(2);
    expect(Array.from(uniqueValues).every(v => v === 0 || v === 255)).toBe(true);
  });

  test('should maintain image dimensions', async () => {
    await otsuBinarization(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 calculate reasonable threshold value', async () => {
    const result = await otsuBinarization(testInputPath, testOutputPath);
    // 閾値が妥当な範囲内にあることを確認
    expect(result.threshold).toBeGreaterThan(0);
    expect(result.threshold).toBeLessThan(255);
  });
});

結果

入力 結果

Discussion