現実の画像との戦い
by nona
誰?
nonaと会社では名乗っています。
Gyazoのバックエンド、インフラ周りを主として見ています。
hiroshiさんと主に2人で見ています。
hiroshiさんの発表はこの後14:40からTrack Bで!
Gyazoの数字
毎日約60万枚の画像がアップロードされています。
つまり月だと2000万枚ぐらい。
現実
これぐらいいっぱい上がっていると、正常ではない画像も上がってくる。
ここでは「異常」をGyazoがうまく扱えない ぐらいの意味で使う。
そもそも画像っぽくないようなファイルがアップロードを試みられることもあるが(これは上がっている画像の枚数に入らないので正確にはわからない)、ここでは最低限画像の体裁を保っているが、メタデータ等がおかしいものについて話す。
重要なメタデータ Exif
Exifとは、画像に埋め込まれることのある、メタデータを保持するフォーマット。
https://gyazo.com/2a9c0914a461800ad4e5cdd4cdec1aad
DC-008-2019 デジタルスチルカメラ用画像ファイルフォーマット規格 Exif 2.32
Exif 3.0の規格が2023年5月29日に出ていた!
まだ読めていないので、2.32をベースに話す。
手元のiPhoneとかで撮ると2.32で記録されているのもある。
タグ名によって中身を取り出せる辞書のような概念をしている。
親切な写真を表示してくれるアプリなら、詳細的なところを押せば出してくれる(プレビュー.appなど)。
ImageMagickが入っていたら、identify -verbose IMG_6938.HEICみたいなコマンドで詳細に読める。
code: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
Gyazoでの扱い
Exifがついている画像では、それを読んで撮影日時/撮影場所などを表示するようになっている。
https://gyazo.com/5df1db34282ef8038f4eaf6cbbb67fe9
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つの並びで分数を表す)。
https://gyazo.com/abfd141910549dc8c7cd3237ccc62a38
DateTimeOriginal
https://gyazo.com/3a9141475a383e5abc52a493fb99edf1
GPSDateStamp
https://gyazo.com/868bc1f69b1ab3f04d4d1008d329d1f2
GPSTimeStamp
表現
このように、DateTimeOriginalとGPSDateStampは文字列が入るようになっている。
これを素直に読んでいると、時々壊れた画像に遭遇する。
実際に遭遇した例だと、GPSDateStampとして2015:02:51のような値が入っている画像がアップロードされてきた。
これを前述のフォーマット通りに解釈すると、2015年2月51日という意味になってしまい、存在しない日付なのでRails側でTime.utcを呼んだ際に例外が飛んで検出された。
文字列による表現だと、なんでも書けるので、日時として正常ではないようなものまで書けてしまう。
https://gyazo.com/cc3e63f0c4b58ac26a6cb2968ef059de
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ならそれを受け入れるように変更して対処。
https://gyazo.com/b9a4e26e7af98c0406f0602f402ed84e
何故このような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時として表示される。
壊れた時刻なのでどう扱うべきか はないが、できるだけそれっぽい物を表示したほうが親切である という方向性は同じように思う。
https://gyazo.com/426a9e201f7e48e2726e0dd07570e7af
まとめ
ユーザがアップロードするコンテンツなので、「正常な」画像ではないものもやってくることがある。
それでもできる限りは受け入れてあげた方が、親切。
どれぐらいがんばるかのさじ加減はむずかしい。
今回のように複数のフィールドがあって普段は使っていない方にフォールバックする などぐらいは良いのではないか。
「現実世界」と、歯を食いしばって向きあっていくことしかできないし、それをしていくのこそ大事なのではないか。
現実と戦う仲間を募集しています。