ユーザーが投稿した写真が、自分の端末では正しい向きで表示されているのに、アップロードした先——サーバーに保存した画像や、別のユーザーの画面でだけ90度横を向く。個人開発で画像を多く扱うアプリを運用していると、何度か出会う厄介な不具合です。私自身、利用者から「アップロードした写真が、友だちの画面では横向きになっている」という報告を受けて、半日ほど原因を追ったことがあります。結論から言えば、写っているピクセルは一度も回転していません。回転の情報は EXIF の Orientation タグ という別の場所に書かれていて、それを読む側と読まない側で見え方が割れていたのです。
この食い違いは、Rork で生成したアプリでも同じ構造で起こります。React Native の <Image> は Orientation タグを尊重して表示してくれますが、アップロード先のサーバーや画像処理ライブラリ、他社サービスのプレビューはタグを無視することが珍しくありません。今日は、撮影・選択した写真を アップロードする前にピクセルへ回転を焼き込み、ついでに位置情報を含む EXIF を削除する までを、動くコードで組み立てます。
端末のプレビューでは正しいのに、転送先でだけ回転する
まず症状を正確に切り分けます。次の3つを観察すると、EXIF Orientation 起因かどうかがほぼ判定できます。
- アプリ内の
<Image> では正しい向きで表示される
- その同じファイルをサーバーに上げ、ブラウザや別ライブラリで開くと横向きになる
- 縦持ちで撮った写真ほど症状が出やすく、横持ちの写真では起きない
この3点が揃ったら、ほぼ Orientation タグの解釈差です。iPhone のカメラは、本体を縦に持って撮っても センサーから来た横向きのピクセルをそのまま保存 し、「表示するときは右に90度回してね」という指示を Orientation タグに書き込みます。タグを読む <Image> は正しく見せ、読まないサーバーは横向きの素のピクセルをそのまま見せる。これが割れの正体です。
原因は EXIF Orientation — ピクセルは回っていない
Orientation タグは 1〜8 の整数で、回転と反転の組み合わせを表します。実務で出会うのはほぼ次の4つです。
| 値 | 意味 | 典型的な発生源 |
| 1 | 回転なし(正位置) | 横持ち撮影・正規化済み画像 |
| 3 | 180度回転 | 上下逆さに持って撮影 |
| 6 | 時計回り90度で正位置になる | 縦持ち撮影(最頻出) |
| 8 | 反時計回り90度で正位置になる | 縦持ち・逆向き |
値 6 が圧倒的に多く、私が報告を受けたケースもすべて 6 でした。重要なのは、値が 1 以外のときは「ピクセルと表示指示がずれている」状態 だということです。アップロード先がタグを無視する以上、こちらでピクセルを物理的に回してタグを 1 に揃えてしまうのが、最も移植性の高い解決策になります。
expo-image-picker が返すものを正しく理解する
expo-image-picker は、exif: true を渡すと選択画像のメタデータを返します。まず原因を観測するため、選んだ写真の Orientation を実際に覗いてみます。
import * as ImagePicker from 'expo-image-picker';
// 選択した画像の EXIF Orientation を確認するための診断用ヘルパー
async function pickAndInspect() {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
exif: true, // EXIF を取得する(既定では付いてこない)
quality: 1,
});
if (result.canceled) return;
const asset = result.assets[0];
// iOS は "Orientation"、Android では文字列キーが異なる場合がある
const orientation =
asset.exif?.Orientation ?? asset.exif?.['{TIFF}']?.Orientation ?? 1;
console.log('orientation =', orientation); // 縦持ち写真ならよく 6 が出る
console.log('size =', asset.width, 'x', asset.height);
return asset;
}
ここで覚えておきたい挙動の違いがあります。iOS のピッカーは、quality を 1 未満にして再エンコードさせると、その過程で回転を焼き込んで Orientation を 1 に正規化してくれることがあります。一方で quality: 1 のまま受け取ると、元ファイルの Orientation がそのまま残りがちです。つまり「quality をいくつにしたか」で挙動が変わるため、ピッカー任せにせず、こちらで明示的に正規化する のが安全だと考えています。
回転をピクセルに焼き込んで正規化する
正規化の本体は expo-image-manipulator です。画像を一度デコードして再エンコードする過程で、Orientation を反映した「正位置のピクセル」を書き出します。再エンコード時には EXIF が引き継がれないため、後述の位置情報削除も同時に達成できます。
import * as ImageManipulator from 'expo-image-manipulator';
// 写真を「正位置のピクセル」に焼き直し、EXIF を落とした JPEG を返す
async function normalizeForUpload(uri: string) {
const manipulated = await ImageManipulator.manipulateAsync(
uri,
[], // 変形リストは空でよい — 再エンコード自体が正規化になる
{
compress: 0.8, // 0〜1。0.8 で見た目の劣化はほぼ気づきません
format: ImageManipulator.SaveFormat.JPEG,
}
);
// manipulated.uri は Orientation=1・EXIF なしの新しいファイルを指す
return manipulated;
}
manipulateAsync は内部で Orientation を解釈してから描画するため、変形リストが空でも「タグの指示を反映した正位置のビットマップ」を書き出します。出力された新しい URI は Orientation が 1 になっているので、タグを読まないサーバーでも正しい向きで保存されます。HEIC で渡ってきた写真も、format: JPEG を指定することで互換性の高い JPEG に揃えられます。
EXIF を確実に削除して位置情報の漏えいを止める
回転だけでなく、EXIF にはユーザーの GPS 座標・撮影日時・端末モデルが含まれている 点を見逃せません。自宅で撮った写真をそのままアップロードすれば、緯度経度が外部に渡ってしまいます。再エンコードで EXIF は落ちますが、本当に消えたかを検証する習慣を持っておくと安心です。
import * as ImageManipulator from 'expo-image-manipulator';
import * as FileSystem from 'expo-file-system';
// 正規化+検証: 出力後に元と新しいファイルのサイズを比べてログに残す
async function safeProcess(originalUri: string) {
const before = await FileSystem.getInfoAsync(originalUri, { size: true });
const out = await ImageManipulator.manipulateAsync(
originalUri,
[{ resize: { width: 1600 } }], // 長辺を 1600px に。投稿用途ならこれで十分
{ compress: 0.8, format: ImageManipulator.SaveFormat.JPEG }
);
const after = await FileSystem.getInfoAsync(out.uri, { size: true });
const ratio = (1 - (after.size ?? 0) / (before.size ?? 1)) * 100;
console.log(`size: ${before.size} -> ${after.size} bytes (-${ratio.toFixed(0)}%)`);
// 出力 JPEG をビューアで開き、位置情報フィールドが空であることを目視確認する
return out.uri;
}
私の手元の検証では、iPhone の縦持ち写真(およそ 3.2MB・Orientation 6)を長辺 1600px・compress: 0.8 で処理すると、おおむね 280〜420KB まで縮みました。転送量にして85%以上の削減 で、しかも向きは正位置、位置情報は除去済みという三重の効果が一度に得られます。App Store のプライバシー要件でも、必要のない位置情報を集めない「データ最小化」は評価される方向であり、投稿前の EXIF 除去はその実装としてそのまま説明できます。
アップロード経路に正規化を必ず挟む
最後に、これを「アップロードの単一の入り口」へ集約します。撮影・ライブラリ選択・ドラッグ&ドロップなど経路が複数あっても、サーバーへ送る直前に必ず normalizeForUpload を通す設計にすると、向きと位置情報の事故を構造的に防げます。
async function uploadPhoto(pickedUri: string, endpoint: string) {
// 1) どの経路から来た画像でも、ここで必ず正規化する
const safeUri = await safeProcess(pickedUri);
// 2) 正位置・EXIF なしのファイルだけをサーバーへ送る
const form = new FormData();
form.append('file', {
uri: safeUri,
name: 'photo.jpg',
type: 'image/jpeg',
} as any);
const res = await fetch(endpoint, { method: 'POST', body: form });
if (!res.ok) throw new Error(`upload failed: ${res.status}`);
return res.json();
}
Rork に実装を頼むときも、「画像はアップロード直前に expo-image-manipulator で再エンコードして Orientation を正規化し、EXIF を削除すること」と条件を明示すると、生成されるコードがこの形に寄ります。曖昧に「画像をアップロード」とだけ伝えると、ピッカーの URI を素のまま送る実装になりがちなので、この一文を足すかどうかで品質が変わります。
本番でハマった点と検証手順
実運用で踏んだ落とし穴を共有します。
- Android の
content:// URI をそのまま fetch しようとして失敗する: manipulateAsync を通すと出力は file:// になるため、正規化を挟むこと自体がこの問題の回避にもなります。
- サムネイルと本体で別々に処理して向きが食い違う: 一覧用サムネイルも本体と同じ正規化関数から作ると、表示の不一致が消えます。
- 再エンコードの劣化を恐れて
compress: 1 にすると、ファイルが縮まない: 投稿用途では 0.8 前後を推奨します。等倍保存が要るのは原寸保管のときだけです。
検証は、(1) 縦持ち・横持ち・上下逆の3枚を用意し、(2) 処理後の URI をサーバーに上げてブラウザで開き、(3) すべて正位置で、位置情報フィールドが空であることを確認する、という手順で十分です。関連して、選んだ画像が開けない段階の不具合はRork で画像ピッカーが起動しないときの対処、端末への保存側の権限設計はexpo-media-library の権限スコープと保存設計が参考になります。
まず1枚、縦持ち写真で試す
最初の一歩として、縦持ちで撮った写真を1枚選び、safeProcess を通した出力をサーバーに上げて、向きと位置情報を確認してみてください。ここが通れば、あとは既存のアップロード経路に normalizeForUpload を1行差し込むだけです。画像はユーザーの生活がそのまま写り込むデータでもあります。向きを直すついでに、知らないうちに位置情報を預かってしまう状態を静かに無くしておくことが、長く使ってもらうアプリの誠実さだと感じています。