📷

画像処理100本ノックに挑戦|減色処理(006/100)

2025/01/07に公開

これはなに?

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

前回

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

実装

お題

ここでは画像の値を256^3から4^3、すなわちR,G,B in {32, 96, 160, 224}の各4値に減色せよ。 これは量子化操作である。 各値に関して、以下の様に定義する。

val = {  32  (  0 <= val <  64)
         96  ( 64 <= val < 128)
        160  (128 <= val < 192)
        224  (192 <= val < 256)

https://github.com/minido/Gasyori100knock-1/tree/master/Question_01_10#q6-減色処理

Coding

愚直に実装すると以下のような条件分岐になるかと思います。
Math.floor(value / 64) * 64 + 32;の一つの数式で表現できるようになり、シンプルでパフォーマンスも高いコードが書けます。

export function quantizeValue(value: number): number {
  if (value < 64) return 32;
  if (value < 128) return 96;
  if (value < 192) return 160;
  return 224;
}
import sharp from 'sharp';

export function quantizeValue(value: number): number {
  // floor(value / 64) * 64 + 32 の実装
  return Math.floor(value / 64) * 64 + 32;
}

export async function quantizeColors(inputPath: string, outputPath: string): Promise<void> {
  try {
    // 画像を読み込む
    const image = await sharp(inputPath)
      .raw()
      .toBuffer({ resolveWithObject: true });

    const { data, info } = image;
    const { width, height, channels } = info;

    // 新しい画像データ用のバッファを作成
    const newData = Buffer.alloc(data.length);

    // 各ピクセルの各チャネルを量子化
    for (let i = 0; i < data.length; i += channels) {
      // RGB各チャネルを量子化(C++のコードと同じロジック)
      newData[i] = quantizeValue(data[i]);         // R
      newData[i + 1] = quantizeValue(data[i + 1]); // G
      newData[i + 2] = quantizeValue(data[i + 2]); // B
      
      // アルファチャネルがある場合はそのままコピー
      if (channels === 4) {
        newData[i + 3] = data[i + 3];
      }
    }

    // 画像を保存
    await sharp(newData, {
      raw: {
        width,
        height,
        channels
      }
    })
    .png({
      quality: 100,
      compressionLevel: 9
    })
    .toFile(outputPath);

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

Test

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

describe('Color Quantization Tests', () => {
  const testInputPath = join(__dirname, '../test-images/test.jpeg');
  const testOutputPath = join(__dirname, '../test-images/test-quantized.png');

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

  test('should quantize values using floor division algorithm', () => {
    const testCases = [
      { input: 0, expected: 32 },    // 0/64 = 0  -> 0*64 + 32 = 32
      { input: 63, expected: 32 },   // 63/64 = 0  -> 0*64 + 32 = 32
      { input: 64, expected: 96 },   // 64/64 = 1  -> 1*64 + 32 = 96
      { input: 127, expected: 96 },  // 127/64 = 1 -> 1*64 + 32 = 96
      { input: 128, expected: 160 }, // 128/64 = 2 -> 2*64 + 32 = 160
      { input: 191, expected: 160 }, // 191/64 = 2 -> 2*64 + 32 = 160
      { input: 192, expected: 224 }, // 192/64 = 3 -> 3*64 + 32 = 224
      { input: 255, expected: 224 }  // 255/64 = 3 -> 3*64 + 32 = 224
    ];

    testCases.forEach(({ input, expected }) => {
      const result = quantizeValue(input);
      expect(result).toBe(expected);
    });
  });

  test('should successfully process image', async () => {
    await expect(quantizeColors(testInputPath, testOutputPath))
      .resolves.not.toThrow();
    expect(existsSync(testOutputPath)).toBe(true);
  });

  test('should maintain image dimensions', async () => {
    await quantizeColors(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 only output specific quantized values', async () => {
    await quantizeColors(testInputPath, testOutputPath);
    
    const outputImage = await sharp(testOutputPath)
      .raw()
      .toBuffer({ resolveWithObject: true });

    const expectedValues = new Set([32, 96, 160, 224]);
    const channels = outputImage.info.channels;
    
    // サンプルピクセルをチェック
    for (let i = 0; i < Math.min(1000, outputImage.data.length); i += channels) {
      for (let c = 0; c < 3; c++) {  // RGB各チャネルをチェック
        const value = outputImage.data[i + c];
        if (!expectedValues.has(value)) {
          console.log(`Unexpected value: ${value} at position ${i + c}`);
        }
        expect(expectedValues.has(value)).toBe(true);
      }
    }
  });
});

結果

入力 結果

Discussion