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?

ブラウザ側で写真を縮小してからbase64でアップロードする(EXIF回転・透過PNG・リトライまで実測)

0
Posted at

個人で開発している Web アプリ Belle AI では、ユーザーが選んだ写真をブラウザ側で base64 にして、JSON で API に送っています。スマホで撮った原画像は数 MB あり、base64 にすると文字列の長さは元のバイト数の約 4/3 倍になります。モバイル回線ではこのリクエストが重くなるので、送る前にブラウザ側で縮小しています。

この記事では、実際に使っている TypeScript のコードを紹介し、その挙動を手元のブラウザで動かして確かめた結果をまとめます。

先に結果

項目 結果
入力 3000×3002 の JPEG、6,511,067 バイト(Exif あり)
出力 2047×2048 の JPEG、771,483 バイト(元の約 11.8%)
base64(data URL)の文字数 8,681,447 → 1,028,667
処理時間 99 ms(1 回だけ計測)
Exif 入力にはあり、出力にはなし

測定条件です。

  • ブラウザは Chromium 148(Electron 42)です。Safari、Firefox、iOS・Android の実機では試していません
  • 入力は NASA の Apollo 17 の写真です(Wikimedia Commons、パブリックドメイン)。自撮り写真ではないので、実際のサイズは写真の内容によって変わります

全体の流れ

コード

imageUpload.ts
const MAX_EDGE = 2048;
const JPEG_QUALITY = 0.88;
/** すでに小さい JPEG は再エンコードせず、そのまま使う(二重圧縮による劣化を避ける) */
const SKIP_COMPRESS_BYTES = 1.2 * 1024 * 1024;

function readAsDataUrl(blob: Blob): Promise<string> {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result as string);
    reader.onerror = () => reject(reader.error ?? new Error("read_failed"));
    reader.readAsDataURL(blob);
  });
}

async function decode(
  file: File
): Promise<{ source: CanvasImageSource; width: number; height: number; release: () => void }> {
  if (typeof createImageBitmap === "function") {
    // "from-image" で Exif の向きを反映してデコードする(縦撮りが横倒しにならない)
    const bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
    return { source: bitmap, width: bitmap.width, height: bitmap.height, release: () => bitmap.close() };
  }

  // createImageBitmap がない環境向け
  const url = URL.createObjectURL(file);
  try {
    const img = await new Promise<HTMLImageElement>((resolve, reject) => {
      const el = new Image();
      el.onload = () => resolve(el);
      el.onerror = () => reject(new Error("decode_failed"));
      el.src = url;
    });
    return { source: img, width: img.naturalWidth, height: img.naturalHeight, release: () => URL.revokeObjectURL(url) };
  } catch (err) {
    URL.revokeObjectURL(url);
    throw err;
  }
}

/** そのままアップロードできる data URL を返す。失敗したとき、または効果がないときは元ファイルを返す */
export async function prepareImageForUpload(file: File): Promise<string> {
  try {
    const { source, width, height, release } = await decode(file);
    try {
      const scale = Math.min(1, MAX_EDGE / Math.max(width, height));
      if (scale === 1 && file.type === "image/jpeg" && file.size <= SKIP_COMPRESS_BYTES) {
        return await readAsDataUrl(file);
      }

      const canvas = document.createElement("canvas");
      canvas.width = Math.max(1, Math.round(width * scale));
      canvas.height = Math.max(1, Math.round(height * scale));
      const ctx = canvas.getContext("2d");
      if (!ctx) return await readAsDataUrl(file);

      // 透過 PNG を JPEG にするとき、透明部分が黒くならないように白で塗る
      ctx.fillStyle = "#ffffff";
      ctx.fillRect(0, 0, canvas.width, canvas.height);
      ctx.drawImage(source, 0, 0, canvas.width, canvas.height);

      const blob = await new Promise<Blob | null>((resolve) =>
        canvas.toBlob(resolve, "image/jpeg", JPEG_QUALITY)
      );
      if (!blob || (scale === 1 && blob.size >= file.size)) return await readAsDataUrl(file);
      return await readAsDataUrl(blob);
    } finally {
      release();
    }
  } catch {
    return readAsDataUrl(file);
  }
}

このアプリでは、顔と肌の色が判別できれば十分なので、長辺 2048px・JPEG 品質 0.88 にしています。この値は用途に合わせた私の判断で、端末や回線ごとの調整はしていません。

動作確認

上のコードを、同じ処理のままブラウザの上で実行して確かめました。

1. デコードと Exif の向き

3000×1500 の JPEG の先頭に、Orientation=6(90 度回転して表示する)の Exif を手で差し込んだファイルを作りました。

// Orientation=6 の APP1(Exif)セグメントを、JPEG の SOI(FF D8)の直後に差し込む
const app1 = new Uint8Array([
  0xFF, 0xE1, 0x00, 0x22,                                          // APP1、長さ 34
  0x45, 0x78, 0x69, 0x66, 0x00, 0x00,                              // "Exif\0\0"
  0x49, 0x49, 0x2A, 0x00, 0x08, 0x00, 0x00, 0x00,                  // TIFF ヘッダ(リトルエンディアン)
  0x01, 0x00,                                                      // IFD のエントリ数 1
  0x12, 0x01, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, // Orientation = 6
  0x00, 0x00, 0x00, 0x00,                                          // 次の IFD なし
]);
const merged = new Uint8Array(jpeg.length + app1.length);
merged.set(jpeg.subarray(0, 2), 0);
merged.set(app1, 2);
merged.set(jpeg.subarray(2), 2 + app1.length);

このファイルを createImageBitmap に渡した結果です。

呼び出し 幅×高さ
オプションなし 1500×3000
imageOrientation: "from-image" 1500×3000
imageOrientation: "none" 1500×3000
imageOrientation: "flipY" 1500×3000
<img> の naturalWidth / naturalHeight 1500×3000

全部、Exif を反映した向き(縦長)でした。MDN には none は Exif を無視すると書かれていますが、この Chromium 148 では none でも Exif が反映されました。HTML 仕様には「以前あった none は from-image に改名された。将来、別の意味で none を戻す」という注記があります。この経緯があるので、コードでは "from-image" を明示しています。

この Exif 付きファイルを prepareImageForUpload に通すと、出力は 1024×2048 になりました。元の左上に置いた白いマーカーは右上に移動しています。ピクセル (10, 10) は青系の (74, 144, 216)、右上端の近くは白 (255, 255, 255) でした。回転は画素に反映済みです。

createImageBitmap がない環境向けの Image を使う経路は、試していません。

2. 縮小と JPEG 化

scale = min(1, 2048 / 長辺) を、3000×3002 に当てはめると約 0.682 で、幅は Math.round で 2047、高さは 2048 になります。縦横比は完全には保たれず、幅の丸め分だけずれます。

透過 PNG は白で塗る

3000×1500 の透過 PNG(円を 1 つ描いたもの、108,202 バイト)で、左上のピクセルを読みました。

白で塗る 左上のピクセル(RGB)
塗る(コードどおり) (255, 255, 255)
塗らない (0, 0, 0)

塗らないと、透明な部分は JPEG で黒になります。

Exif は出力に残らない

ファイルの先頭 64KB に Exif\0\0 が含まれるかを調べました。入力の JPEG にはあり、canvas から toBlob した出力にはありませんでした。撮影日時や位置情報などのメタデータは、再エンコードで送られなくなります。ただし、この実験で確認したのは Exif ヘッダの有無だけです。

3. 何もしない条件と、元ファイルに戻す条件

  • 縮小が不要で、JPEG で、1.2MB 以下のとき、元ファイルをそのまま使います。800×400・3,304 バイトの JPEG で試すと、戻り値は元ファイルの data URL と完全に一致しました
  • 縮小なしで再エンコードしたのに元より大きくなったとき、デコードや描画で例外が出たときも、元ファイルに戻します。この 2 つの分岐は、コードを読んだだけで、実行して確かめていません

4. アップロードのタイムアウトとリトライ

api.ts
const UPLOAD_MAX_ATTEMPTS = 3;
const UPLOAD_TIMEOUT_MS = 45_000;

/**
 * 画像をアップロードする。モバイル回線ではタイムアウトや切断が起きやすいので、
 * タイムアウトと最大 3 回のリトライ(待ち時間は 1 秒、2 秒)を付けている。
 * リトライするのはネットワークエラー、HTTP 5xx、業務コード 500 のみ。
 * 4xx やそれ以外の業務エラーは再送せず、そのまま呼び出し側に返す。
 * (抜粋・簡略化。URL と送信フィールドの一部は省略)
 */
export async function uploadImage(base64Data: string): Promise<ApiResponse<{ url: string }>> {
  let lastError: unknown = null;

  for (let attempt = 1; attempt <= UPLOAD_MAX_ATTEMPTS; attempt++) {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), UPLOAD_TIMEOUT_MS);
    try {
      const res = await fetch(`${API_BASE}/upload`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ imageBase64: base64Data }),
        signal: controller.signal,
      });

      if (res.status >= 500 && attempt < UPLOAD_MAX_ATTEMPTS) {
        lastError = new Error(`HTTP ${res.status}`);
      } else {
        const json = (await res.json()) as ApiResponse<{ url: string }>;
        if (json.code === 500 && attempt < UPLOAD_MAX_ATTEMPTS) {
          lastError = new Error(json.message || "upload_server_error");
        } else {
          return json;
        }
      }
    } catch (err) {
      lastError = err;
      if (attempt === UPLOAD_MAX_ATTEMPTS) throw err;
    } finally {
      clearTimeout(timer);
    }

    await new Promise((resolve) => setTimeout(resolve, attempt * 1000));
  }

  throw lastError instanceof Error ? lastError : new Error("upload_failed");
}

fetch を差し替えた状態で、同じロジックを動かした結果です。タイムアウトだけ、テストのため 45 秒から 300ms に縮めています。

シナリオ 試行回数 結果
HTTP 503、503、200 3 回(0 秒、約 1.0 秒、約 3.0 秒) 3 回目で成功
HTTP 400 + JSON 1 回 再送せず、その JSON を返す
ネットワークエラー ×3 3 回(0 秒、約 1.0 秒、約 3.0 秒) 最後のエラー(TypeError)を throw
HTTP 200 + 業務コード 500、次は成功 2 回 1 秒待って再送し、成功
応答なし(テスト用 300ms でタイムアウト)、次は成功 2 回(0 秒、約 1.3 秒) abort の後に 1 秒待って再送し、成功
HTTP 503 ×3(本文は {}) 3 回 3 回目は throw せず、本文の JSON を返す

最後の行は注意が必要です。最後の試行が 5xx のとき、この実装は例外を投げず、レスポンス本文をそのまま返します(本文が JSON でなければ res.json() が例外になります)。呼び出し側で uploadRes.code !== 200 を確認しているので、このアプリでは失敗として扱われます。

限界と注意

  • テストは Chromium 148 だけです。Safari、Firefox、iOS・Android の実機、HEIC は試していません
  • 測定に使った画像は自撮りではなく、公有領域の風景写真 1 枚です。サイズの削減率は、写真の内容で変わります
  • JPEG の再エンコードなので、画質は落ちます。品質 0.88 と長辺 2048px は固定値です
  • リトライは同じ画像の再送です。サーバー側で重複を除いているかは、クライアントからは分かりません
  • 処理時間は 1 回の計測で、端末ごとの比較はしていません

おわりに

写真を縮小してから base64 で送る処理を、コードと実測値で整理しました。ブラウザ側の画像処理の参考になれば幸いです。この処理は、写真から AI でパーソナルカラーなどを診断する Web アプリ Belle AI の試用ページで使っています。

この記事の下書きには AI を使っています。記載した数値と挙動は、手元のブラウザでコードを動かして確認したものです。

参考

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?