📷

画像処理100本ノックに挑戦|HSV変換(005/100)

2025/01/06に公開

これはなに?

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

前回

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

実装

お題

HSV変換を実装して、色相Hを反転せよ。

https://github.com/minido/Gasyori100knock-1/tree/master/Question_01_10#q5-hsv変換

Coding

import sharp from 'sharp';

interface HSV {
  h: number;  // 0-360
  s: number;  // 0-1
  v: number;  // 0-1
}

export function rgbToHsv(rOrRgb: number | [number, number, number], g?: number, b?: number): HSV {
  let r: number, gb: number, bb: number;
  
  if (Array.isArray(rOrRgb)) {
    [r, gb, bb] = rOrRgb;
  } else {
    r = rOrRgb;
    gb = g!;
    bb = b!;
  }

  // 以下、既存の実装
  const red = r / 255;
  const green = gb / 255;
  const blue = bb / 255;

  const max = Math.max(red, green, blue);
  const min = Math.min(red, green, blue);
  const diff = max - min;

  let h = 0;
  const s = max === 0 ? 0 : diff / max;
  const v = max;

  if (max !== min) {
    if (max === red) {
      h = 60 * ((green - blue) / diff) + (green < blue ? 360 : 0);
    } else if (max === green) {
      h = 60 * ((blue - red) / diff) + 120;
    } else if (max === blue) {
      h = 60 * ((red - green) / diff) + 240;
    }
  }

  return { h, s, v };
}

export function hsvToRgb(hsv: HSV): [number, number, number] {
  const { h, s, v } = hsv;
  
  const c = v * s;
  const hPrime = h / 60;
  const x = c * (1 - Math.abs((hPrime % 2) - 1));
  
  let r = 0, g = 0, b = 0;

  if (0 <= hPrime && hPrime < 1) { [r, g, b] = [c, x, 0]; }
  else if (1 <= hPrime && hPrime < 2) { [r, g, b] = [x, c, 0]; }
  else if (2 <= hPrime && hPrime < 3) { [r, g, b] = [0, c, x]; }
  else if (3 <= hPrime && hPrime < 4) { [r, g, b] = [0, x, c]; }
  else if (4 <= hPrime && hPrime < 5) { [r, g, b] = [x, 0, c]; }
  else if (5 <= hPrime && hPrime < 6) { [r, g, b] = [c, 0, x]; }

  const m = v - c;
  
  return [
    Math.round((r + m) * 255),
    Math.round((g + m) * 255),
    Math.round((b + m) * 255)
  ];
}

export async function invertHue(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) {
      const r = data[i];
      const g = data[i + 1];
      const b = data[i + 2];

      const hsv = rgbToHsv(r, g, b);
      hsv.h = (hsv.h + 180) % 360;
      const [newR, newG, newB] = hsvToRgb(hsv);

      newData[i] = newR;
      newData[i + 1] = newG;
      newData[i + 2] = newB;
      if (channels === 4) {
        newData[i + 3] = data[i + 3];
      }
    }

    await sharp(newData, {
      raw: {
        width,
        height,
        channels
      }
    })
    .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 { invertHue, rgbToHsv, hsvToRgb } from './imageProcessor';

describe('HSV Color Conversion Tests', () => {
  test('should convert pure red RGB to HSV', () => {
    const hsv = rgbToHsv(255, 0, 0);
    expect(hsv.h).toBeCloseTo(0);
    expect(hsv.s).toBeCloseTo(1);
    expect(hsv.v).toBeCloseTo(1);
  });

  test('should convert pure green RGB to HSV', () => {
    const hsv = rgbToHsv(0, 255, 0);
    expect(hsv.h).toBeCloseTo(120);
    expect(hsv.s).toBeCloseTo(1);
    expect(hsv.v).toBeCloseTo(1);
  });

  test('should convert pure blue RGB to HSV', () => {
    const hsv = rgbToHsv(0, 0, 255);
    expect(hsv.h).toBeCloseTo(240);
    expect(hsv.s).toBeCloseTo(1);
    expect(hsv.v).toBeCloseTo(1);
  });

  test('should convert HSV back to RGB correctly', () => {
    const originalRgb: [number, number, number] = [255, 0, 0];
    const hsv = rgbToHsv(originalRgb);  // 配列として渡すことができる
    const [r, g, b] = hsvToRgb(hsv);
    expect(r).toBeCloseTo(255);
    expect(g).toBeCloseTo(0);
    expect(b).toBeCloseTo(0);
  });

  test('should handle black correctly', () => {
    const hsv = rgbToHsv(0, 0, 0);
    expect(hsv.h).toBe(0);
    expect(hsv.s).toBe(0);
    expect(hsv.v).toBe(0);
  });

  test('should handle white correctly', () => {
    const hsv = rgbToHsv(255, 255, 255);
    expect(hsv.h).toBe(0);
    expect(hsv.s).toBe(0);
    expect(hsv.v).toBe(1);
  });
});

// 画像処理の統合テスト
describe('Image Processing Tests', () => {
  const testInputPath = join(__dirname, '../test-images/test.jpeg');
  const testOutputPath = join(__dirname, '../test-images/test-inverted.jpg');

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

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

  test('should handle non-existent input file', async () => {
    const nonExistentPath = 'non-existent.jpg';
    await expect(invertHue(nonExistentPath, testOutputPath))
      .rejects
      .toThrow();
  });
});

結果

入力 結果

Discussion