📷

画像処理100本ノックに挑戦|アフィン変換(回転)(030/100)

2025/02/01に公開

これはなに?

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

https://zenn.dev/nyagato_00/articles/9b7aa704309aaa

実装

お題

(1)アフィン変換を用いて、反時計方向に30度回転させよ。

(2) アフィン変換を用いて、反時計方向に30度回転した画像で中心座標を固定することで、なるべく黒い領域がなくなるように画像を作成せよ。 (ただし、単純なアフィン変換を行うと画像が切れてしまうので、工夫を要する。)

https://github.com/minido/Gasyori100knock-1/tree/master/Question_21_30#q30-アフィン変換回転

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
  adjustCenter?: boolean; // 中心座標を調整するかどうか
}

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);
    const newHeight = Math.round(height);

    // 新しいバッファーを作成(黒で初期化)
    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('アフィン変換行列が特異です');
    }

    // 中心座標の計算用の配列
    let xCoords: number[] = [];
    let yCoords: number[] = [];

    // まず全座標を計算して中心調整のオフセットを求める
    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
        );
        
        xCoords.push(srcX);
        yCoords.push(srcY);
      }
    }

    // 中心座標の調整値を計算
    let dcx = 0;
    let dcy = 0;
    if (params.adjustCenter) {
      dcx = Math.round((Math.max(...xCoords) + Math.min(...xCoords)) / 2 - width / 2);
      dcy = Math.round((Math.max(...yCoords) + Math.min(...yCoords)) / 2 - height / 2);
    }

    // 各ピクセルに対して変換を適用
    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
        ) - dcx;
        const srcY = Math.round(
          (-params.c * x + params.a * y) / adbc - params.ty
        ) - dcy;

        // 元画像の範囲内かチェック
        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('画像処理を開始します...');

    // 回転角度(度数法)
    const angle = 30;
    // ラジアンに変換
    const theta = -(angle * Math.PI) / 180;

    // Case 1: 単純な回転
    await affineTransform(inputPath, outputPath1, {
      a: Math.cos(theta),   // 回転行列の要素
      b: -Math.sin(theta),  // 回転行列の要素
      c: Math.sin(theta),   // 回転行列の要素
      d: Math.cos(theta),   // 回転行列の要素
      tx: 0,
      ty: 0,
      adjustCenter: false
    });
    console.log('単純回転の処理が完了しました');

    // Case 2: 中心座標を固定した回転
    await affineTransform(inputPath, outputPath2, {
      a: Math.cos(theta),
      b: -Math.sin(theta),
      c: Math.sin(theta),
      d: Math.cos(theta),
      tx: 0,
      ty: 0,
      adjustCenter: true    // 中心座標の調整を有効化
    });
    console.log('中心固定回転の処理が完了しました');
  } catch (error) {
    console.error('プログラムの実行に失敗しました:', error);
  }
}

main();

結果

入力 出力(1) 出力(2)

Discussion