📷

画像処理100本ノックに挑戦|アフィン変換(拡大縮小)(029/100)

2025/01/31に公開

これはなに?

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

前回

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

実装

お題

アフィン変換を用いて、(1)x方向に1.3倍、y方向に0.8倍にリサイズせよ。

また、(2) (1)の条件に加えて、x方向に+30、y方向に-30だけ平行移動を同時に実現せよ。

https://github.com/minido/Gasyori100knock-1/tree/master/Question_21_30#q29-アフィン変換拡大縮小

Coding

imageProcessor.ts

import sharp from 'sharp';

interface AffineParams {
  a: number;  // scale x
  b: number;  // shear x
  c: number;  // shear y
  d: number;  // scale y
  tx: number; // translate x
  ty: number; // translate y
}

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

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

    // アフィン変換後の新しいサイズを計算
    const newWidth = Math.round(width * params.a);
    const newHeight = Math.round(height * params.d);

    // 新しいバッファーを作成(黒で初期化)
    const newBuf = Buffer.alloc(newWidth * newHeight * channels);

    // アフィン変換の逆行列の係数を計算
    const adbc = params.a * params.d - params.b * params.c;
    if (Math.abs(adbc) < 1e-10) {
      throw new Error('アフィン変換行列が特異です');
    }

    // 各ピクセルに対して変換を適用
    for (let y = 0; y < newHeight; y++) {
      for (let x = 0; x < newWidth; x++) {
        // 逆変換で元の座標を求める
        const srcX = Math.round(
          (params.d * x - params.b * y) / adbc - params.tx
        );
        const srcY = Math.round(
          (-params.c * x + params.a * y) / adbc - params.ty
        );

        // 元画像の範囲内かチェック
        if (
          srcX >= 0 && srcX < width &&
          srcY >= 0 && srcY < height
        ) {
          // 元のピクセル位置と新しいピクセル位置を計算
          const srcPos = (srcY * width + srcX) * channels;
          const destPos = (y * newWidth + x) * channels;

          // ピクセルデータをコピー
          for (let c = 0; c < channels; c++) {
            newBuf[destPos + c] = data[srcPos + c];
          }
        }
      }
    }

    // 新しい画像を作成して保存
    await sharp(newBuf, {
      raw: {
        width: newWidth,
        height: newHeight,
        channels
      }
    })
    .toFile(outputPath);

    console.log('アフィン変換が完了しました');
  } catch (error) {
    console.error('アフィン変換中にエラーが発生しました:', error);
    throw error;
  }
}

index.ts

import * as path from 'path';
import { affineTransform } from './imageProcessor';

async function main() {
  // __dirnameを使用して相対パスを解決
  const inputPath = path.join(__dirname, '../imori.jpg');
  const outputPath1 = path.join(__dirname, '../output_scale.jpg');
  const outputPath2 = path.join(__dirname, '../output_scale_translate.jpg');

  try {
    console.log('画像処理を開始します...');
    

    // Case 1: 拡大縮小のみ
    // x方向に1.3倍、y方向に0.8倍
    await affineTransform(inputPath, outputPath1, {
      a: 1.3,  // x方向の拡大率
      b: 0,    // xのシアー
      c: 0,    // yのシアー
      d: 0.8,  // y方向の縮小率
      tx: 0,   // x方向の移動なし
      ty: 0    // y方向の移動なし
    });
    console.log('拡大縮小の処理が完了しました');

    // Case 2: 拡大縮小 + 平行移動
    // x方向に1.3倍かつ+30移動、y方向に0.8倍かつ-30移動
    await affineTransform(inputPath, outputPath2, {
      a: 1.3,  // x方向の拡大率
      b: 0,    // xのシアー
      c: 0,    // yのシアー
      d: 0.8,  // y方向の縮小率
      tx: 30,  // x方向の移動量
      ty: -30  // y方向の移動量
    });
    console.log('拡大縮小と平行移動の処理が完了しました');
  } catch (error) {
    console.error('プログラムの実行に失敗しました:', error);
  }
}

main();

結果

入力 出力(リサイズ) 出力(平行移動)

Discussion