0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

画像の回転・反転をCanvasで実装する — translate+rotateのアフィン変換と、操作を重ねるときのJPEG劣化

0
Posted at

3行まとめ

  • 画像を90°/180°回転・左右上下反転できるブラウザ完結ツールを作った。Canvas の translate + rotate / scale によるアフィン変換だけで、ライブラリなしで実装できる
  • rotate()原点(0,0)を軸に回すので、先に translate で原点を正しい角へ動かさないと、回転した画像がキャンバスの外に飛び出す。90°回転ではキャンバスの縦横も入れ替える
  • 「回転→反転→回転」と操作を重ねる設計では、毎回前の結果Blobを再エンコードするため、JPEGだと世代劣化が乗る。だから出力品質は0.92固定、透過が要るときはPNG(可逆)に逃がす

スマホで撮った写真が横向きのまま投稿サイトに上がってしまう、スキャンした書類が逆さま——画像の向きを直すだけの操作は地味に頻出するのに、そのために画像編集アプリを開くのは重い。

ぱんだツールズの画像回転・反転ツールは、アップロードした画像を90°/180°回転・左右反転・上下反転できるツール。画像はブラウザ内で処理され、サーバーに送信されない。

やっていることは Canvas 2D のアフィン変換だけで、外部ライブラリは使っていない。この記事では、回転・反転の座標変換をどう組んだか、90°回転でキャンバスの縦横を入れ替える理由、そして「操作を積み重ねられる」UIの裏にある再エンコードのコストを、実装ベースで解説する。

回転で縦横が入れ替わる

まず画像を Image に読み込み、naturalWidth / naturalHeight を取る。90°回転(左右どちらでも)では、出力画像の縦横が元と入れ替わるので、キャンバスのサイズもスワップする。180°回転と反転はサイズが変わらない。

const srcW = img.naturalWidth
const srcH = img.naturalHeight

let canvasW: number
let canvasH: number

if (transform === 'rotate-left' || transform === 'rotate-right') {
  canvasW = srcH   // 縦横入れ替え
  canvasH = srcW
} else {
  canvasW = srcW
  canvasH = srcH
}

const canvas = document.createElement('canvas')
canvas.width = canvasW
canvas.height = canvasH

ここを忘れると、横長画像を90°回転したときにキャンバスが横長のままで、回転後の縦長画像が左右で見切れる。回転で「絵が入る箱」の形が変わることを先にキャンバス側へ反映しておく。

rotate() は原点を軸に回る — だから先に translate

Canvas の ctx.rotate(θ) は、座標系の原点(0,0)を中心に座標系ごと回転させる。画像は drawImage(img, 0, 0) で左上(0,0)基準に描くので、原点を軸に回すと画像はキャンバスの見える範囲の外へ回り込んでしまう。そこで、回転後の画像がちょうど (0,0)〜(canvasW, canvasH) に収まるよう、rotate の前に translate で原点を「回転の軸にすべき角」へ移動させる。

5つの変換はすべてこの「translate してから rotate / scale」で表現できる。

ctx.save()
switch (transform) {
  case 'rotate-left':                 // 反時計回り90°
    ctx.translate(0, srcW)
    ctx.rotate(-Math.PI / 2)
    break
  case 'rotate-right':                // 時計回り90°
    ctx.translate(srcH, 0)
    ctx.rotate(Math.PI / 2)
    break
  case 'rotate-180':
    ctx.translate(srcW, srcH)
    ctx.rotate(Math.PI)
    break
  case 'flip-h':                      // 左右反転
    ctx.translate(srcW, 0)
    ctx.scale(-1, 1)
    break
  case 'flip-v':                      // 上下反転
    ctx.translate(0, srcH)
    ctx.scale(1, -1)
    break
}
ctx.drawImage(img, 0, 0)
ctx.restore()

考え方はどれも同じ。原点を出力キャンバスの適切な角へ translate で移し、その角を軸に回す(または反転する)。回すと画像が原点から左や上のマイナス方向へ回り込むので、その分を先に足してキャンバス内に収める、と考えると各値が読める。

  • 時計回り90°では、出力キャンバスは srcH × srcW。原点を右上の角 (srcH, 0) へ移してから +90° 回すと、drawImage(img, 0, 0) した画像がちょうどキャンバス全体を埋める(例えば元画像の左上 (0,0) は出力の右上 (srcH, 0) へ、元の左下 (0, srcH) は出力の左上 (0, 0) へ移る)
  • 180°回転なら原点を右下の角 (srcW, srcH) へ動かして 180°
  • 左右反転は scale(-1, 1) でX軸を反転させるが、そのままだと画像が左(マイナス方向)へ描かれるので、translate(srcW, 0) で幅ぶん右へ寄せてから反転する

反転を「回転の特殊ケース」ではなく scale のマイナス倍で表現しているのがポイント。回転はアフィン変換の回転成分、反転はスケール成分で、Canvas の変換行列はこれらを同じ枠組みで扱える。だから回転と反転を別々の分岐にしても、実装の骨格(translate → 変換 → drawImage)は共通化できる。

変換の前後を ctx.save() / ctx.restore() で挟んでいるのは、キャンバスに積んだ変換行列をこの1回の描画だけに閉じ込めるため。使い捨てのキャンバスなので必須ではないが、変換の副作用を局所化しておくと、あとで別の描画を足したくなったときに壊れない。

出力とフォーマット — JPEGは0.92、PNGは品質指定を無視する

描き終わったら canvas.toBlob でエンコードする。JPEGのときだけ品質を渡し、PNGでは品質引数を undefined にする。

const mimeType = getOutputMimeType(outputFormat)     // 'image/png' or 'image/jpeg'
const quality = outputFormat === 'jpeg' ? 0.92 : undefined

const blob = await new Promise<Blob>((resolve, reject) => {
  canvas.toBlob((b) => {
    if (!b) { reject(new Error('画像の生成に失敗しました')); return }
    resolve(b)
  }, mimeType, quality)
})

toBlob の第3引数(品質)は image/jpegimage/webp でしか効かず、PNG は可逆圧縮なので無視される。なので PNG のときは渡しても意味がなく、意図を明確にするため undefined にしている。入力ファイルの拡張子から出力フォーマットの初期値も決めていて(.png なら PNG、それ以外は JPEG)、透過画像をうっかりJPEGにして背景が黒く潰れる事故を減らす。

「操作を重ねられる」UIの裏で起きていること

このツールは「右90°回転してから左右反転」のように操作を何度でも積み重ねられる。実装上これは、直前の変換結果のBlobを次の入力にすることで実現している。

// 変換後、結果Blobを「現在の画像」として保持
currentBlobRef.current = blob
// 次の操作はこのBlobを元に applyTransform される

useState ではなく useRefcurrentBlobRef)で現在の画像を保持しているのは、連続操作で最新のBlobを同期的に参照したいから。state だと非同期更新のタイミングで古いBlobを掴む可能性がある。「元の画像に戻す」を押すと currentBlobRef を最初の File に差し戻す。

ここに正直に書いておくべきトレードオフがある。操作を重ねるたびに applyTransform が走り、Canvas に描いて toBlob再エンコードする。PNG は可逆なので画質は落ちないが、JPEGは非可逆なので操作のたびに世代劣化が積み重なる。5回回転を繰り返せば5回JPEG圧縮がかかる。品質を0.92と高めに固定しているのはこの劣化を目立たなくするためだが、原理的にゼロにはできない。回転・反転は本来ピクセルの並べ替えだけで劣化しないはずの操作なので、劣化を嫌うなら操作を重ねずPNGで出すのが安全。ここは「操作を積み重ねられる手軽さ」と「再エンコードのコスト」を天秤にかけた設計判断になっている。

そのつど古いプレビュー用のObject URLは URL.revokeObjectURL で解放して、Blobを積み重ねてもメモリリークしないようにしている。

if (previewUrl) URL.revokeObjectURL(previewUrl)
const newUrl = URL.createObjectURL(blob)
setPreviewUrl(newUrl)

EXIFの向き問題と、ピクセルに焼き込むこと

スマホ写真は「センサーの向き」と「表示すべき向き」のズレを、画像を回転させて保存するのではなく EXIF の Orientation タグに「90°回して表示して」と書いて済ませていることが多い。EXIFを読むアプリでは正しく表示されるが、読まないサービスでは横向き・逆さまで出る。

このツールで回転・反転して保存すると、変換結果はピクセルデータそのものが正しい向きで並んだ画像になる。向きの情報がメタデータ頼みでなく実際のピクセル配置として焼き込まれるので、EXIFを解釈しないサービスに持っていっても向きが狂わない。回転という単純な操作でも、「メタデータで向きを指示する」のと「ピクセルを実際に並べ替える」のは別物、というのがこのツールの実質的な価値。

まとめ

  • 画像の回転・反転は Canvas 2D の translate + rotate / scale だけでライブラリなしに実装できる
  • rotate() は原点を軸に回すので、先に translate で「回転後に画像の左上が来る座標」へ原点を移す。90°回転ではキャンバスの縦横もスワップする
  • 反転は scale(-1, 1) / scale(1, -1)。回転も反転も同じアフィン変換の枠組みで、実装の骨格は共通化できる
  • toBlob の品質引数はJPEG/WebPのみ有効でPNGでは無視される。操作を重ねる設計はJPEG世代劣化を伴うので、品質0.92固定+PNG退避でケアする
  • 回転結果はピクセルに焼き込まれるため、EXIF Orientation を読まないサービスでも向きが崩れない

スマホ写真やスキャン書類の向きを直したいときにどうぞ。画像はブラウザの外に出ない。

ぱんだツールズ では他にも 画像圧縮・リサイズ・形式変換・PDF処理・CSV変換など、ブラウザ完結で使えるツールを多数公開中。全部無料・登録不要・ファイルはサーバーに送られない。
https://sakutto-panda.com


この記事は Zenn にも同じ内容を投稿しています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?