Google Apps Scriptで画像ファイルの回転方向を正す【GAS】

前回、AppSheetで名刺管理アプリを作ってるときに、カメラを横向きで撮影すると画像の方向が横のままの画像で登録されるという現象に悩まされました。スマホを縦で撮影すれば良いのですが、横で取ってしまった時に気になる。Geminiは横向きでもきちんとOCRしてくれるので大きな問題にはなっていないものの正しい方向にしたいのが人間の性。

しかしGASでやるには色々とハードルがありたどり着いた方法が今回の方法になります。

今回利用する素材

今回のテーマは前回のAppSheetで名刺読み取り管理ツールからの派生になります。Google Apps Scriptのみでどうにかこの画像の方向を自動検出して出来ないかなと四苦八苦してみました。課題が思った以上にあり、実現は出来ましたがトリッキーな手法となります。

AppSheetで名刺読み取り管理ツールを作る【GAS】

画像を正しい方向にする上での課題

ExifのRotationの値

AppSheetのカメラにて撮影時において、縦横で撮影した画像のEXIFの値を調べてみました。横撮影の時は1、縦撮影の時は6となっています。ただし、Google Apps Scriptの場合、Drive APIでこのrotationの値を取れるのですが、ドライブにアップした時点で勝手にrotationの値が全部どのパターンでも「0」になってしまう。

故に今回、画像の回転方向についてはGeminiにアップしてOCRさせた時点で、ついでに縦横判定をさせるようにし、返り値に含めるように変更しました。以下のコードはExif情報を取得するコードですが今回は使えません。

//ファイルのメタ情報を取得
const file = Drive.Files.get(fileId, {
      fields: 'name, imageMediaMetadata'
});

//EXIF情報を取得
const exif = file.imageMediaMetadata;

//回転方向を取得
console.log(`回転情報 (時計回り): ${exif.rotation !== undefined ? exif.rotation : 'N/A'} 度`);

図:横向いてる画像はrotationが1になってる

GASでは画像を回転させられない

GASでどうにか画像を左回転90°回転できないかな?と考えたのですが、そのような手段は存在しませんでした。Geminiを使って画像の回転が出来ないかと思ったのですが、Gemini 2.0 Flash Preview Image Generatorで回転は実現できるか?と思ったのですが、確かに一見すると回転できていてOKかと思ったら画像の一部がおかしなことに(ChatGPTは実はこれがバッチリ実現出来ます)

故に正攻法ではGASを使って画像の回転は実現できないとわかりました。

Google Apps ScriptとGemini 2.0 Flash 画像編集で出力する【GAS】

Geminiで画像の回転方向を判定させる

前回記事では写真から文字を読み取ってフィールドに収めるようにしています。一方でAppSheetで撮影した画像は縦横がひっくりかえることがあったり無かったりと不安定。そこで、Geminiでこれを判定するプロンプトを追加して、値で返してもらいrotationがverticalの時だけ画像の加工をするようにフラグとして利用しようと考えました。

以下はそのプロンプトです。これでうまいこと判定してくれるので、出力事例のrotationの値を元に画像の回転を実施するかどうかを決めるようにしました。

・・・・前略・・・・

    #出力する内容
    出力事例に従った配列形式でデータは返してください。読み取り項目の格納先各要素名は以下の通り
    - 会社名は、company
    - 名刺の氏名は、name
    - 所属部署名は、busyo
    - 名刺本人の役職名は、jobtitle
    - 電話番号は、phonenumber
    - メールアドレスは、mail
    - 画像の文字の向きは、rotation

    #画像の文字の向きについて
    画像の中の文字が左90°や右90°で回転してることがあります。回転してる場合はvertical, とくに回転していない場合はhorizontalで
    値は返してください。

    #出力事例
    {
      "company" : "谷山ベース整備店",
      "name" : "相良左之助",
      "busyo" : "メンテナンス部",
      "jobtitle" : "チーフマネージャー",
      "phonenumber" : "090-1234-5678",
      "mail" : "sagara@taniyamabase.com",
      "rotation": "vertical"
    }

Googleスライドを使って編集する

最近のスマホのカメラで撮影する画像はとにかくデカい。デフォルトだと3000x4000みたいな、そんなサイズいる?というサイズで撮影されます。結果1枚が2MBを超過するので通信環境的にもドライブの容量的にも優しくありません。

今回の主たるテーマではないのですが、AppSheetで撮影しても同様のことが言え、ファイルサイズを軽量化する上でも画像の加工が必要ということで、裏テーマとして画像のファイルサイズを小さくすることも目標として取り入れています。

しかし、直接的な画像の編集がGASではどうがんばっても出来ない中、「Googleスライドに貼り付けてエクスポート」をすればサイズが小さくなるということと、ここで画像の回転も可能ということなのでGASでそれをなぞる方法を実現しました。

但し、カスタムサイズでのスライド作成が出来ないというバグが2018年から放置され続けてるので、現状は16:9のワイドスクリーンでのみスライドを作成可能です。以下はGoogle Slides APIを使ってチャレンジして失敗したコードです。

//カスタムサイズで一時スライドを作成する関数
function createSlideWithCustomSize() {
  const token = ScriptApp.getOAuthToken();
  const url = 'https://slides.googleapis.com/v1/presentations';

  const payload = {
    title: 'My Custom Size Presentation',
    pageSize: {
      width:  { magnitude: 1024, unit: 'PT' },
      height: { magnitude: 768, unit: 'PT' }
    }
  };

  const options = {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify(payload),
    headers: {
      Authorization: 'Bearer ' + token
    },
    muteHttpExceptions: true     
  };

  // REST API を叩く
  const response = UrlFetchApp.fetch(url, options);
  const result = JSON.parse(response.getContentText());

  // 新しく作成されたプレゼンテーションID(=fileId)
  const presentationId = result.presentationId;

  // 返り値として返す
  return presentationId;
}

ソースコード

一時スライドを作成する

今回の手法は、撮影した画像をGoogleスライドに貼り付けて加工します。そのための一時的なスライドを作成します。以下の関数で作ってスライドのファイルを呼び出し元に返します(マイドライブルート直下に作成しています)。

このスライドは画像加工後にGASによって自動的に削除されます。

function createSlideFromTemplate() {
  // 新しく作成するスライドのファイル名を指定
  const presentationName = '新しいプレゼンテーション';

  // 新しいスライドを作成
  const presentation = SlidesApp.create(presentationName);
  
  // 作成されたプレゼンテーションオブジェクトを返す
  return presentation.getId();
}

画像を回転し再出力させる

撮影された画像ファイルのIDを元に前回記事のGASの関数であるgeminiReader関数から呼び出されます。Geminiからの返り値のrotationの値で判定してからrotateImageWithApi関数にファイルIDを渡します。

スプレッドシートに画像を挿入後、以下のような手順で回転処理をしエクスポートします。

  1. 空のスライドファイルを作成後に画像をまず追加する
  2. 90°左回転を実行する
  3. 回転後の画像の横サイズを元の画像の縦横比3:4の比率でリサイズを実行(横幅のみを伸ばす)
  4. スライドの上下に余白が出来るので、フィットさせるために縮尺率を再計算
  5. 画像をスケールさせてアスペクト比を維持したままリサイズを実行する
  6. 画像をスライドの真ん中にセンタリングする
  7. PNG形式でしかエクスポートが出来ないのでまずPNG形式で出力
  8. その後JPG画像へと変換をかける
  9. JPG画像をドライブに生成してファイルIDを返す

呼び出し元は変換後ファイルはDrive APIを利用して削除を実施しています(エラー発生時にも残さないように削除を実行)

function rotateImageWithApi(originalImageId) {
  let presentationId = null;

  try {
    //ファイルを取得する
    const originalFile = DriveApp.getFileById(originalImageId);
    const imageBlob = originalFile.getBlob();

    //メタ情報を取得する
    const fileMetadata = Drive.Files.get(originalImageId, {
      fields: 'name, parents, imageMediaMetadata/width, imageMediaMetadata/height'
    });
    
    //親フォルダを取得
    const parentFolderId = fileMetadata.parents[0];
    const parentFolder = DriveApp.getFolderById(parentFolderId);

    // 横長スライド
    const slideWidthPt = 576;
    const slideHeightPt = 768;
    presentationId = createSlideFromTemplate();

    const presentationToEdit = SlidesApp.openById(presentationId);
    const slide = presentationToEdit.getSlides()[0];
    const pageId = slide.getObjectId();
    
    //イメージ挿入して左90°回転
    const image = slide.insertImage(imageBlob);
    image.setRotation(-90);

    // 回転後を想定した上で、スライドに合わせるための縮尺率を計算
    let currentHeight = image.getHeight();
    let currentWidth = image.getHeight();
    const newWidth = (currentHeight * 3) / 4;
    
    //計算した「正しい横幅」のみを画像に設定する
    image.setWidth(newWidth);

    //センタリングする
    image.alignOnPage(SlidesApp.AlignmentPosition.CENTER);

    //再度縦横のサイズを取得する
    currentHeight = image.getHeight();
    currentWidth = image.getHeight();

    //フィットするスケールを計算
    const ratioW = slideWidthPt / currentWidth;
    const ratioH = slideHeightPt / currentHeight;
    const scaleFactor = Math.min(ratioW, ratioH);

    //再度縦横比を割り当ててスケールアップ
    image.scaleWidth(scaleFactor);
    image.scaleHeight(scaleFactor);

    //センタリングする
    image.alignOnPage(SlidesApp.AlignmentPosition.CENTER);
    
    //プレゼンファイルを保存する
    presentationToEdit.saveAndClose();

    //PNGでしかエクスポート出来ない
    const thumbnailUrl = Slides.Presentations.Pages.getThumbnail(presentationId, pageId, {
      "thumbnailProperties.mimeType": "PNG",
      "thumbnailProperties.thumbnailSize": "LARGE"
    }).contentUrl;
    
    const response = UrlFetchApp.fetch(thumbnailUrl, {
      headers: { Authorization: 'Bearer ' + ScriptApp.getOAuthToken() }
    });
    
    //PNGをJPEGに変換
    const pngBlob = response.getBlob();
    const jpegBlob = pngBlob.getAs('image/jpeg').setName(`${originalFile.getName()}_rotated_fitted.jpg`);
    
    //親フォルダ探索してファイル作成
    const newFile = parentFolder.createFile(jpegBlob);

    //ファイルIDを返す
    return newFile.getId();

  } catch(e) {
    console.error(`エラーが発生しました: ${e.toString()}\nスタックトレース: ${e.stack}`);
  } finally {
    if (presentationId) {
      Drive.Files.remove(presentationId);
    }
  }
}

関連リンク

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)