generated at
現実の画像との戦い
by nona

誰?
nonaと会社では名乗っています。
Gyazoのバックエンド、インフラ周りを主として見ています。
hiroshiさんと主に2人で見ています。
hiroshiさんの発表はこの後14:40からTrack Bで!

Gyazoの数字
毎日約60万枚の画像がアップロードされています。
つまり月だと2000万枚ぐらい。

現実
これぐらいいっぱい上がっていると、正常ではない画像も上がってくる。
ここでは「異常」をGyazoがうまく扱えない ぐらいの意味で使う。
そもそも画像っぽくないようなファイルがアップロードを試みられることもあるが(これは上がっている画像の枚数に入らないので正確にはわからない)、ここでは最低限画像の体裁を保っているが、メタデータ等がおかしいものについて話す。

重要なメタデータ Exif
Exifとは、画像に埋め込まれることのある、メタデータを保持するフォーマット。
Exif 2.32 https://www.cipa.jp/j/std/std-sec.html をベースに話す。
> DC-008-2019 デジタルスチルカメラ用画像ファイルフォーマット規格 Exif 2.32
Exif 3.0の規格が2023年5月29日に出ていた!
まだ読めていないので、2.32をベースに話す。
手元のiPhoneとかで撮ると2.32で記録されているのもある。
タグ名によって中身を取り出せる辞書のような概念をしている。
親切な写真を表示してくれるアプリなら、詳細的なところを押せば出してくれる(プレビュー.appなど)。
ImageMagickが入っていたら、 identify -verbose IMG_6938.HEIC みたいなコマンドで詳細に読める。
identify
nana@29-er % identify -verbose IMG_6938.HEIC Image: Filename: /Users/nana/Downloads/IMG_6938.HEIC Permissions: rw-r--r-- Format: HEIC (High Efficiency Image Format) Mime type: image/heic Class: DirectClass Geometry: 4032x3024+0+0 Resolution: 72x72 Print size: 56x42 Units: PixelsPerInch Colorspace: sRGB Type: TrueColor Base type: Undefined Endianness: Undefined Depth: 8-bit Channels: 3.0 Channel depth: Red: 8-bit Green: 8-bit Blue: 8-bit Channel statistics: Pixels: 12192768 Red: min: 0 (0) max: 255 (1) mean: 132.991 (0.521534) median: 135 (0.529412) standard deviation: 67.4032 (0.264326) kurtosis: -1.02447 skewness: -0.187482 entropy: 0.987241 Green: min: 0 (0) max: 255 (1) mean: 108.568 (0.425757) median: 98 (0.384314) standard deviation: 62.3799 (0.244627) kurtosis: -0.843173 skewness: 0.33102 entropy: 0.979629 Blue: min: 0 (0) max: 255 (1) mean: 80.4016 (0.3153) median: 65 (0.254902) standard deviation: 60.7963 (0.238417) kurtosis: -0.268552 skewness: 0.813811 entropy: 0.955198 Image statistics: Overall: min: 0 (0) max: 255 (1) mean: 107.32 (0.420864) median: 99.3333 (0.389542) standard deviation: 63.5264 (0.249123) kurtosis: -0.712066 skewness: 0.319116 entropy: 0.974023 Rendering intent: Perceptual Gamma: 0.454545 Chromaticity: red primary: (0.64,0.33,0.03) green primary: (0.3,0.6,0.1) blue primary: (0.15,0.06,0.79) white point: (0.3127,0.329,0.3583) Matte color: grey74 Background color: white Border color: srgb(223,223,223) Transparent color: black Interlace: None Intensity: Undefined Compose: Over Page geometry: 4032x3024+0+0 Dispose: Undefined Iterations: 0 Compression: Undefined Orientation: TopLeft Profiles: Profile-exif: 2890 bytes Profile-icc: 536 bytes Properties: date:create: 2024-08-23T06:00:24+00:00 date:modify: 2023-11-22T07:46:26+00:00 date:timestamp: 2024-08-24T09:21:28+00:00 exif:ApertureValue: 27767/23734 exif:BrightnessValue: 35704/13243 exif:ColorSpace: 65535 exif:DateTime: 2023:11:22 16:45:46 exif:DateTimeDigitized: 2023:11:22 16:45:46 exif:DateTimeOriginal: 2023:11:22 16:45:46 exif:ExifOffset: 224 exif:ExifVersion: 0232 exif:ExposureBiasValue: 0/1 exif:ExposureMode: 0 exif:ExposureProgram: 2 exif:ExposureTime: 1/60 exif:Flash: 16 exif:FNumber: 3/2 exif:FocalLength: 57/10 exif:FocalLengthIn35mmFilm: 26 exif:GPSAltitude: 328761/8341 exif:GPSAltitudeRef: . exif:GPSDateStamp: 2023:11:22 exif:GPSDestBearing: 196960/1021 exif:GPSDestBearingRef: T exif:GPSHPositioningError: 35/1 exif:GPSImgDirection: 196960/1021 exif:GPSImgDirectionRef: T exif:GPSInfo: 2574 exif:GPSLatitude: 33/1,35/1,2992/100 exif:GPSLatitudeRef: N exif:GPSLongitude: 130/1,25/1,1183/100 exif:GPSLongitudeRef: E exif:GPSSpeed: 0/1 exif:GPSSpeedRef: K exif:GPSTimeStamp: 7/1,45/1,4334/100 exif:LensMake: Apple exif:LensModel: iPhone 13 Pro back triple camera 5.7mm f/1.5 exif:LensSpecification: 299253/190607, 299253/190607, 299253/190607, 299253/190607 exif:Make: Apple exif:MakerNote: Apple iOS exif:MeteringMode: 5 exif:Model: iPhone 13 Pro exif:OffsetTime: +09:00 exif:OffsetTimeDigitized: +09:00 exif:OffsetTimeOriginal: +09:00 exif:PhotographicSensitivity: 100 exif:PixelXDimension: 4032 exif:PixelYDimension: 3024 exif:SceneType: . exif:SensingMethod: 2 exif:ShutterSpeedValue: 449728/76141 exif:Software: 17.1.1 exif:SubjectArea: 2009, 2009, 2009, 2009 exif:SubSecTimeDigitized: 487 exif:SubSecTimeOriginal: 487 exif:WhiteBalance: 0 icc:copyright: Copyright Apple Inc., 2022 icc:description: Display P3 signature: 13eccfc42d9ce04a19e5af54847a0fd0acfd0bb3ca21824075ec9099136aa988 unknown: iPhone 13 Pro Artifacts: verbose: true Tainted: False Filesize: 1.9588MiB Number pixels: 12.1928M Pixel cache type: Memory Pixels per second: 70.9392MP User time: 0.530u Elapsed time: 0:01.171 Version: ImageMagick 7.1.1-36 Q16-HDRI aarch64 22352 https://imagemagick.org

Gyazoでの扱い
Exifがついている画像では、それを読んで撮影日時/撮影場所などを表示するようになっている。
Gyazoの画像ページをキャプチャしたもの(これを映すと少しややこしい)。
これの、「撮影日時」、「撮影場所」、「近くの画像」はexifから読んだものを活用している。
具体的に以下のようなフィールドがある。これらについて今回は主に話す。
DateTimeOriginal
GPSDateStamp
GSPTimeStamp
DateTimeOriginalのほうのタイムゾーンが取得できていないので、GPSのフィールドがあればそちらを優先して使うようにしている。
OffsetTimeOriginalのようなタイムゾーンを保持するフィールドが規格の上では定義されているが、今使ってるlibexifのbindingでは何故か取得できていない。
手元の写真ではこれも記録されている。
これは今後の課題ですね。
位置情報が含まれるGPSLatitudeなどのフィールドも読んでいるが今回は説明は割愛。


詳細
それぞれ以下のようになっている。
DateTimeOriginal
ASCII 20バイトで、 YYYY:MM:DD HH:MM:SS のようにフォーマットする。
2024:08:25 11:06:34 みたいな文字列
GPSDateStamp
ASCII 11バイトで、 YYYY:MM:DD のようにフォーマットする。
2024:08:25 みたいな文字列
GPSTimeStamp
時、分、秒の並んだ3つのRATIONAL(LONG型2つの並びで分数を表す)。
DateTimeOriginal
GPSDateStamp
GPSTimeStamp

表現
このように、DateTimeOriginalとGPSDateStampは文字列が入るようになっている。
これを素直に読んでいると、時々壊れた画像に遭遇する。
実際に遭遇した例だと、GPSDateStampとして 2015:02:51 のような値が入っている画像がアップロードされてきた。
これを前述のフォーマット通りに解釈すると、2015年2月51日という意味になってしまい、存在しない日付なのでRails側で Time.utc を呼んだ際に例外が飛んで検出された。
文字列による表現だと、なんでも書けるので、日時として正常ではないようなものまで書けてしまう。
2024:03:21 20:32:69
つい数日前にも2024年3月21日 20時32分69秒みたいな時刻を持った文字列が入っている画像があった。
Sentryは古い通知は消えてしまうので、丁度いい……(が、本来は来ない方が望ましい)。

なんで時刻のフィールドがstringなんですか。
直感的には、unix timeや、epochミリ秒とかで入っていてほしいと思うが、なぜそうなっていないのか?
そうであれば、日時としてinvalidなものが入ることはない。
規格書には「どう定めた」かは書かれていても、「何故そう定めた」かは書かれていないのでわからない。詳細を知っている人がいたら教えてください。
(以下は妄想)
初期の実装がこうなっていて互換性の結果だったりするのだろうか?
日時までしかわからない時に途中まで書く みたいなことをする実装が存在する?
文字列の長さは規格では定まっているが、フィールドに長さも入っているため、従わずに20byteでないものを入れることは(ファイルフォーマット上は)可能。
規格ではそれが許されているわけではないが、「読む際は寛容に、書く際は厳格に」(ロバストネス原則)に従った方が良いので、そういう実装があったら許すほかない?
規格には以下のようにある。
>原画像データの作成された日付と時間。DSCでは撮影された日付と時間を記録する。フォーマットは“YYYY:MM:DD HH:MM:SS"。 時間は 24 時間表示し、日付と時間の間に空白文字を1つ埋める。日時不明の場合は、コロン": "以外の日付・時間の文字部を空白文字で埋めるか、または、すべてを空白文字で埋めるべきである。文字列の長さは、NULL を含み 20Byte である。 記載が無いときは不明として扱う。
CIPA DC-008-2019 デジタルスチルカメラ用画像ファイルフォーマット規格 Exif 2.32 P47
これは全部書く or 書かない を指示しているように読める。
: : : : か空文字列が許されている というだけ?
わからないとこだけ空白はどうなんだ?
単純にこのフィールドを表示するだけのような挙動をするビューワなどがある?
GPS系のフィールドが分かれているのは何故なのか? という疑問もある。
これは秒以下の精度をGPSからなら取得できる可能性があるからか?
であったとしても、時、分までをRationalにする理由はあるのだろうか?
実装上の都合?
とは言え、実はSubSecTimeのようなDateTime系フィールド用の秒以下の精度フィールドもあるしな……。
歴史についてはわからないので、追加された時期とかの違いもあるのか?

対処
今回のようなケース 2015:02:51 については、DateTimeOriginalフィールドとGPSのフィールドについて、本来は後者を優先しているが前者だけでもvalidならそれを受け入れるように変更して対処。
何故このようなExifを持った画像が届くのかは不明。
ユーザの手元で何らかの化け等が発生している?
これはたまたま日付としてありえないような表現に化けているが、ありえる表現同士で化けていたり、そもそも画像の中の1Byteが変化してても気付く術は無いので発見は困難。
アップロードされている間はTLSの上を通っているので、変化することは無いはずだが?(化けると通信が失敗するので) ユーザの手元で化けている?
厳密にはこちらが受け取ってGoogle Cloud Storageに書き込むまでの間に化けている可能性はあるか?

他の壊れた時刻
他にも、2021年ごろのAndroidエミュレータのカメラアプリで撮影した画像について、DateTimeフィールドに 2021:05:25 24:00:47 のように0時であるところに24という値が入ってくることなどもあった。
この本当の撮影時刻は5/25の0時。
これも何故このような画像が生成されているのかまでは追えていないため不明。
現象として、24時として渡されるものは0時として解釈するのが正しいようにされたので、24時は0時として扱うように変更。
hour = hour % 24 みたいなコード。
しかし普段の会話での24時のような表記(つまり26日の0時)をする処理系も世の中には存在しそう。
Google Photosに入れると26日の0時として表示される。
壊れた時刻なのでどう扱うべきか はないが、できるだけそれっぽい物を表示したほうが親切である という方向性は同じように思う。

まとめ
ユーザがアップロードするコンテンツなので、「正常な」画像ではないものもやってくることがある。
それでもできる限りは受け入れてあげた方が、親切。
どれぐらいがんばるかのさじ加減はむずかしい。
今回のように複数のフィールドがあって普段は使っていない方にフォールバックする などぐらいは良いのではないか。
「現実世界」と、歯を食いしばって向きあっていくことしかできないし、それをしていくのこそ大事なのではないか。
現実と戦う仲間を募集しています。