📷

画像処理100本ノックに挑戦|アフィン変換(スキュー)(031/100)

2025/02/02に公開

これはなに?

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

前回

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

実装

お題

(1)アフィン変換を用いて、出力(1)のようなX-sharing(dx = 30)画像を作成せよ。

(2)アフィン変換を用いて、出力2のようなY-sharing(dy = 30)画像を作成せよ。

(3)アフィン変換を用いて、出力3のような幾何変換した(dx = 30, dy = 30)画像を作成せよ。

このような画像はスキュー画像と呼ばれ、画像を斜め方向に伸ばした画像である。

出力(1)の場合、x方向にdxだけ引き伸ばした画像はX-sharingと呼ばれる。

出力(2)の場合、y方向にdyだけ引き伸ばした画像はY-sharingと呼ばれる。

それぞれ次式のアフィン変換で実現できる。 ただし、元画像のサイズがh x wとする。

(1) X-sharing                  (2) Y-sharing
   a = dx / h                     a = dy / w

  x'       1 a tx    x           x'       1 0 tx    x
[ y' ] = [ 0 1 ty ][ y ]       [ y' ] = [ a 1 ty ][ y ]
  1        0 0  1    1           1        0 0  1    1

https://github.com/minido/Gasyori100knock-1/tree/master/Question_31_40#q31-アフィン変換スキュー

Coding

imageProcessor.ts

import sharp from 'sharp';

interface SkewParams {
  dx: number;  // X方向のスキュー量
  dy: number;  // Y方向のスキュー量
}

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

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

    // アフィン変換のパラメータを計算
    const a = 1;                  // スケールX
    const b = params.dx / H;      // X-sharing
    const c = params.dy / W;      // Y-sharing
    const d = 1;                  // スケールY
    const tx = 0;
    const ty = 0;

    // 新しい画像サイズを計算
    const H_new = Math.ceil(params.dy + H);
    const W_new = Math.ceil(params.dx + W);

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

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

    // 一時的な画像バッファー(パディング付き)
    const tempBuf = Buffer.alloc((W + 2) * (H + 2) * channels);
    
    // 元画像を一時バッファーの中心にコピー
    for (let y = 0; y < H; y++) {
      for (let x = 0; x < W; x++) {
        for (let c = 0; c < channels; c++) {
          const srcPos = (y * W + x) * channels + c;
          const destPos = ((y + 1) * (W + 2) + (x + 1)) * channels + c;
          tempBuf[destPos] = data[srcPos];
        }
      }
    }

    // 各ピクセルに対して変換を適用
    for (let y_new = 0; y_new < H_new; y_new++) {
      for (let x_new = 0; x_new < W_new; x_new++) {
        // 逆変換で元の座標を求める
        const x = Math.round((d * x_new - b * y_new) / adbc) + 1;
        const y = Math.round((-c * x_new + a * y_new) / adbc) + 1;

        // 範囲内に収める
        const x_clipped = Math.min(Math.max(x, 0), W + 1);
        const y_clipped = Math.min(Math.max(y, 0), H + 1);

        // ピクセルデータをコピー
        for (let c = 0; c < channels; c++) {
          const srcPos = (y_clipped * (W + 2) + x_clipped) * channels + c;
          const destPos = (y_new * W_new + x_new) * channels + c;
          newBuf[destPos] = tempBuf[srcPos];
        }
      }
    }

    // 新しい画像を作成して保存
    await sharp(newBuf, {
      raw: {
        width: W_new,
        height: H_new,
        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 outputPath1 = path.join(__dirname, '../output_x_sharing.jpg');
  const outputPath2 = path.join(__dirname, '../output_y_sharing.jpg');
  const outputPath3 = path.join(__dirname, '../output_xy_sharing.jpg');

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

    // Case 1: X-sharing (dx = 30)
    await skewTransform(inputPath, outputPath1, {
      dx: 30,
      dy: 0
    });
    console.log('X-sharing の処理が完了しました');

    // Case 2: Y-sharing (dy = 30)
    await skewTransform(inputPath, outputPath2, {
      dx: 0,
      dy: 30
    });
    console.log('Y-sharing の処理が完了しました');

    // Case 3: X-Y sharing (dx = 30, dy = 30)
    await skewTransform(inputPath, outputPath3, {
      dx: 30,
      dy: 30
    });
    console.log('X-Y sharing の処理が完了しました');
  } catch (error) {
    console.error('プログラムの実行に失敗しました:', error);
  }
}

main();

結果

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

Discussion