はじめに
普通に立って撮った全身写真が、「脚が検出できません」「座っている可能性があります」で弾かれる。テスト環境で社内の検証が始まった初日に、そういう事態がおきました。
弊社で開発した LINE 上の写真キャンペーンでの話です。アップロードされた全身写真を、サーバ側で姿勢推定モデルにかけて「全身が写っているか」「立っているか」をチェックしています。
原因は写真の中身ではなく、JPEG の EXIF に入っている Orientation(向き)でした。アップロード前にブラウザで Orientation どおりに回転して保存し直し、失敗したときのためにサーバ側にも同じ処理を入れて直しました。
何が起きていたか
iPhone を縦に構えて撮った写真は、ピクセルは横向きのまま保存され、EXIF の Orientation に「表示するときは時計回りに90度回す」(値 6)と書かれます。
from PIL import Image
img = Image.open("rejected.jpg")
print(img.size, img.getexif().get(0x0112)) # (4032, 3024) 6
ブラウザやスマホの写真アプリはこの値を見て回してから表示するので、人の目には普通の縦写真に見えます。ところが姿勢推定モデルにはピクセルをそのまま渡していたので、モデルからは人が横に寝ているように見えていました。
公開後の約3週間(10/8〜10/27)の本番で、ブラウザ側の記録が残っている2,583枚を数えると、次のとおりでした。
| 写真の状態 | 枚数 |
|---|---|
| EXIF あり・Orientation=6 | 730 |
| EXIF あり・Orientation=8 | 31 |
| EXIF あり・Orientation=2〜5(反転・180度) | 10 |
| EXIF あり・Orientation=1(回転不要) | 1,490 |
| EXIF なし(スクリーンショットやアプリで書き出した画像など) | 320 |
| ブラウザでの変換に失敗(後述) | 2 |
回転が必要な写真は771枚、約3割です。
ブラウザで回転してからアップロードする
サーバ側では姿勢推定・背景除去・合成と、いくつもの処理が同じ画像を読みます。処理ごとに回転させると回し忘れや向きのずれが起きるので、アップロード前にブラウザで回転を済ませることにしました。
ブラウザには画像を回転して保存する機能がないので、canvas に描いて JPEG に書き出します。createImageBitmap に imageOrientation: 'from-image' を渡すと、向きを反映した状態でデコードされます。
// EXIF の向きを反映した状態でデコードする
const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' })
const canvas = document.createElement('canvas')
canvas.width = bitmap.width
canvas.height = bitmap.height
canvas.getContext('2d')?.drawImage(bitmap, 0, 0)
// canvas に描いた時点で正しい向きになっている。書き出した JPEG に EXIF は付かない
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, 'image/jpeg', 0.92),
)
書き出した JPEG には EXIF が付かないので、サーバ側で二重に回転されることはありません。位置情報も一緒に消えます。
引っかかったところ
Orientation の値ではなく、EXIF の有無で判定する
最初は「Orientation が 1 以外なら回転する」条件でした。しかし自前の EXIF の読み取りが値を取りこぼすと、横倒しのまま通ってしまいます。そこで、EXIF を持つ JPEG は値に関係なく必ず canvas を通すように変えました(JPEG 先頭の APP1 セグメントが Exif\0\0 で始まるかで見ています)。回転のいらない写真も再エンコードされますが、品質0.92・解像度そのままなので見た目は変わりません。
古い Android の WebView では from-image が TypeError になる
更新が止まった Android System WebView では、imageOrientation: 'from-image' が TypeError になります。最初はこの例外を握りつぶして元の画像を送っていたので、iOS では通る写真が Android では失敗するという事態がおきました。
今は、オプションなしの createImageBitmap と、<img> に読み込んで canvas に描く方法を順に試します。古いブラウザではどちらも向きを無視することがあるので、回転が必要な写真のときは、今のブラウザなら向きを反映する <img> を先に使います。本番で1つ目の方法が使えなかったのは25枚(約1%)で、そのうち回転が必要だった3枚は <img> で正しい向きになりました。
サーバ側でも回転する
ブラウザでの変換に失敗したときは、元の画像をそのまま送ります(止めると、ユーザーが先に進めなくなるため)。公開前の検証では、ある Android 端末の縦写真が7枚中5枚、横倒しのまま届いて弾かれました。
そこで検閲の前に、サーバ側でも Orientation を確認しています。検閲をまとめる Lambda は画像処理ライブラリを持たないので、S3 から先頭128KBだけを Range 指定で読んで Orientation を取り出し、回転が必要なときだけ Pillow を入れた別の Lambda で回転して、同じキーに上書きします。
import io
import boto3
from PIL import Image, ImageOps
s3 = boto3.client("s3")
# Orientation の確認は先頭だけ読めば足りる(EXIF は先頭の APP1 に入っている)
head = s3.get_object(Bucket=bucket, Key=key, Range="bytes=0-131071")["Body"].read()
# 回転が必要なときだけ、全体を読んで回転する
img = Image.open(io.BytesIO(body))
fixed = ImageOps.exif_transpose(img)
out = io.BytesIO()
# EXIF は落とす(位置情報も残さない)。色の見え方を変えないよう ICC プロファイルだけ引き継ぐ
icc = img.info.get("icc_profile")
fixed.convert("RGB").save(out, format="JPEG", quality=92, **({"icc_profile": icc} if icc else {}))
s3.put_object(Bucket=bucket, Key=key, Body=out.getvalue(), ContentType="image/jpeg")
回転に失敗しても検閲は止めず、ログを残すようにしています。
公開後の約3週間で、ブラウザでの変換に失敗したのは2枚でした。どちらも iPhone(iOS 17.6.1・LINE アプリ内ブラウザ)の 5712×4284 の写真で、canvas.toBlob が null を返していました(canvas のサイズ上限に当たったと見ていますが、未確認です)。2枚ともサーバ側で回転され、横倒しで弾かれずに済んでいます。
おわりに
写真を開いてもスクリーンショットを送ってもらっても正しい向きで表示されるので、見た目からは原因に気づけませんでした。「普通の写真なのに弾かれる」と言われたら、判定の基準をいじる前に、画像の幅・高さと Orientation を見てみてください。
アップロードされた写真を EXIF の向きどおりに回転してから処理するのは、よく使われるやり方です(下の参考文献を参照)。
参考文献
-
ImageSharp: Orientation(Six Labors):アップロードされた写真は、早い段階で
AutoOrient()で向きをそろえる - How to handle EXIF rotation(CloudFactory):ツールによって EXIF の扱いが違うので、回転してから回転の情報を消す
- Images get rotated randomly(fast.ai forums):学習用の画像が EXIF の向きのせいで回って読み込まれた例
-
サイトに画像アップロードすると向きがおかしくなる問題(Qiita):Rails で
auto_orientを入れて直した例 - ImageOps.exif_transpose(Pillow)
- createImageBitmap()(MDN)