JPEGのEXIFは再圧縮せずに消せる — 残すものを繋ぎ直すだけ
写真をそのまま上げると、撮影地の緯度経度が付いてくる。
消すだけなら画像ライブラリで開いて保存し直せばいいんだけど、それは再圧縮になる。手元で試したら、1200x800の写真の色成分288万個のうち22万個が変わった。何も加工していないのに画質だけ落ちる。
JPEGのメタデータは、画像データに指一本触れずにバイト列を繋ぎ直すだけで落とせる。EXIF確認・除去ツールはそれをやっている。依存を足さずに書いたので、その中身を書く。
以下の数字は、sharpで作った1200x800・品質82のJPEG(EXIFとGPS入り、155,174バイト)をsrc/lib/tools/exif.tsに食わせた実測。
JPEGはセグメントの列
JPEGはFFD8(SOI)で始まり、マーカーの付いたセグメントが並び、FFDA(SOS)から先が圧縮された画像データ、最後がFFD9(EOI)。
セグメントはFF + マーカー + 2バイトの長さ + 中身で、長さは自分自身の2バイトを含む。だからセグメント全体は長さ+2バイト。
テスト用の写真を頭から歩くとこうなった。
| 位置 | マーカー | 長さ | 中身 |
|---|---|---|---|
| 2 | APP1 (FFE1) | 472 | Exif\0\0II*\0… |
| 476 | DQT | 67 | 量子化テーブル |
| 545 | DQT | 67 | 量子化テーブル |
| 614 | SOF0 | 17 | サイズと成分 |
| 633 | DHT | 28 | ハフマン表 |
| 663 | DHT | 61 | ハフマン表 |
| 726 | DHT | 28 | ハフマン表 |
| 756 | DHT | 55 | ハフマン表 |
| 813 | SOS (FFDA) | — | ここから先154,361バイトが画像データ |
撮影情報は先頭のAPP1に入っている。Exif\0\0が目印。DQT・SOF0・DHTは復号に要るものなので、落とすとファイルが壊れる。
歩くときに1つ罠がある。長さを持たないマーカーが混じる。TEM(FF01)とRSTn(FFD0〜FFD7)は2バイトで終わりで、続く2バイトを長さとして読むと位置が飛んで以降が全部ずれる。
if (marker === SOS || marker === EOI) break
// 長さを持たないマーカー(TEM・RSTn)
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
segments.push({ marker, start: offset, end: offset + 2 })
offset += 2
continue
}
const length = (data[offset + 2] << 8) | data[offset + 3]
除去は残す側で定義する
セグメントの境目が分かれば、除去は接ぎ木で済む。SOIを書いて、残すセグメントを順に書いて、SOS以降をそのまま後ろにくっつける。
const { segments, tail } = scanSegments(data)
const kept = segments.filter((segment) => !isMetadata(data, segment))
const out = new Uint8Array(size)
out.set(data.subarray(0, 2), 0)
let at = 2
for (const segment of kept) {
out.set(data.subarray(segment.start, segment.end), at)
at += segment.end - segment.start
}
out.set(data.subarray(tail), at)
結果は155,174バイトから154,700バイト。減ったのはちょうどAPP1の474バイト分だけ。SOS以降の154,361バイトは前後で完全に一致していて、復号したピクセルも288万成分すべて一致した。
比較のために、同じ画像を同じ品質82で入れ直したときも測った。
| やり方 | サイズ | SOS以降のバイト列 | 復号したピクセル |
|---|---|---|---|
| セグメントの接ぎ | 154,700 | 一致 | 288万成分すべて一致 |
| 品質82で再エンコード | 151,779 | 別物 | 224,772成分が変化(最大59) |
再エンコードのほうが小さいのは、画質を削っているからで、得ではない。
EXIFの中身はTIFF
Exif\0\0の後ろからはTIFFのバイト列になる。先頭2バイトがバイト順の宣言で、IIならリトルエンディアン、MMならビッグエンディアン。次の2バイトが42で、これが読めなければ以降を読まない。
同じ内容をII/MMで組んで並べると、こうなる。
II: 49 49 2a 00 08 00 00 00 MM: 4d 4d 00 2a 00 00 00 08
以降の16ビット・32ビットをどちらで読むかが全部これで決まる。両対応と言ってもreadU16/readU32にフラグを渡すだけなので、面倒なのは対応そのものではなく、対応を忘れた箇所が1つでもあると値が桁違いになって出てくることのほう。
IFDのエントリは12バイト固定で、タグ2 + 型2 + 個数4 + 値4。ここで最初に踏むのが、値の4バイトが値そのものだったりオフセットだったりする仕様。型のサイズと個数の積が4バイトに収まればエントリの中に直に入り、超えるとTIFF先頭からのオフセットになる。
実測のIFD0とGPS IFDを並べるとこう。
| タグ | 型 | 個数 | 値のバイト数 | 置き場 |
|---|---|---|---|---|
| 0x010f Make | ASCII | 6 | 6 | オフセット |
| 0x0112 Orientation | SHORT | 1 | 2 | エントリの中 |
| 0x011a XResolution | RATIONAL | 1 | 8 | オフセット |
| 0x8769 ExifIFD | LONG | 1 | 4 | エントリの中 |
| 0x0001 GPSLatitudeRef | ASCII | 2 | 2 | エントリの中 |
| 0x0002 GPSLatitude | RATIONAL | 3 | 24 | オフセット |
Canonのような5文字の文字列が、NUL込みで6バイトになってオフセット行きになるのが分かりやすい。1文字ずれで置き場が変わる。
もう1つ、エントリに直に入る値は左詰めで置かれる。SHORTは4バイトの前2バイトを使い、後ろ2バイトは詰め物になる。MMでも同じで、00 06のあとに00 00が続く。
II: 12 01 03 00 01 00 00 00 | 06 00 00 00
MM: 01 12 00 03 00 00 00 01 | 00 06 00 00
ビッグエンディアンだから後ろ詰めだろう、と思って値の位置を4バイト目から数えると、そこには詰め物しかない。
GPSは度分秒で入っている
GPSの緯度経度はRATIONALが3つで、度・分・秒。10進度に直すのは割り算3つで済む。
return degrees + minutes / 60 + seconds / 3600
引っかかるのは符号が値の側に無いところで、南緯か北緯かはGPSLatitudeRefという別のタグにASCIIの1文字で入っている。緯度の値だけ読むと、南半球の写真が北半球に出る。
実測ではN 35/1 41/1 228/10とE 139/1 41/1 305/10が35.689667・139.691806になった。東京駅のあたり。秒が228/10のように分数で来るので、ここを整数のまま読むと22.8秒が228秒になる。緯度が0.057度、距離にして6kmほど飛ぶ。
消したと言うときに何を残しているか
ここからが本題という気がする。
除去を「EXIFを落とす」と書くと取りこぼす。同じ画像にXMP・IPTC・COMが平気で同居しているし、Exif APP1が2つ入っている画像もある(readExifは先頭だけ読み、除去は両方落とす)。
いちばん引っかかったのがXMP。APP1の長さフィールドは2バイトなので、中身は最大65,533バイト。XMPは先頭にhttp://ns.adobe.com/xap/1.0/\0の29バイトを置くから、本体に入るパケットは65,504バイトまで。これを超えると残りはhttp://ns.adobe.com/xmp/extension/のAPP1に分かれて入る。
だから本体の名前空間だけを見る実装は、拡張側しか持たない画像を素通しする。手で組んで試したら、素朴な部分文字列検査はfalse、両方見る実装はtrueになった。
対策として、落とす側でなく残す側を列挙することにした。
/**
* Exif の APP1・XMP の APP1・IPTC の APP13・COM のコメントを取り除いた JPEG を返す。
* 残すのは SOI・APP0(JFIF)・APP2(ICC プロファイル)・TEM・RSTn と画像データ。
*/
知らないAPPnが来たら落とす。列挙を間違えたときに、素通しではなく余計に消す側へ倒れてほしいので。
それでも、消したあとのファイルは無名にはならない。ICCプロファイルは意図的に残しているし、量子化テーブルも当然残る。同じ画像を品質を変えて出すとテーブルの先頭はこう変わる。
| 品質 | 量子化テーブルの先頭8係数 |
|---|---|
| 50 | 16,11,12,14,12,10,16,14 |
| 82 | 6,4,4,5,4,4,6,5 |
| 95 | 2,1,1,1,1,1,2,1 |
除去後のファイルは元と同じ6,4,4,5,4,4,6,5のまま。エンコーダの設定はバイト列に残っていて、これは消しようがない(消したら復号できない)。メタデータを消したというのは、名前と日時と座標を消したという意味であって、それ以上ではない。
Orientationも一緒に消える
トレードオフが1つある。撮って出しのJPEGは、センサーの向きのまま保存してOrientationで回して見せていることが多い。メタデータを消すとこれも消える。
Orientationに6(時計回りに90度)を入れた画像で測った。元は表示上800x1200、除去後は1200x800。横倒しになる。
向きを焼き込むには画素を回して入れ直すしかなくて、それは再圧縮になる(実測で155,672バイトの画像が152,629バイトになり、ピクセルも変わる)。再圧縮しないことを選ぶなら、回転は諦めるほうに倒れる。ツールは倒れたままにしてある。
壊れた入力で投げない
自前パーサはユーザーが選んだファイルを直に読むので、途中で切れたファイルも壊れたファイルも来る。全部のreadが範囲外でnullを返すように書いて、nullが来たらそこで打ち切る。
function readU16(tiff: Tiff, offset: number): number | null {
if (offset < 0 || offset + 2 > tiff.end) return null
...
}
効いているか測った。テスト画像を先頭0バイトから155,174バイトまで1バイト刻みで切った155,175通りと、先頭4,000バイトの1〜8バイトをランダムに壊した20,000通りを流して、readExifとstripMetadataの例外は0件。除去の出力が入力より大きくなったケースも0件だった。
そのぶん、壊れた画像を渡すと黙って空の結果が返る。エラーで止めるより、読めたところまで出したほうがこのツールでは使いやすいと思ってそうしている。
除去をバイト列の接ぎ木として書くと、画像には指一本触れずに済む。そのかわり、何を消したのかは「落とすもの」ではなく「残すもの」の列挙でしか定義できない。知らないAPPnが来たら落とす、と決めておけば、列挙を間違えたときに素通しではなく余計に消す側へ倒れる。EXIFの中身がTIFFで、II/MM両対応と「4バイトに収まる値はエントリに直に入る」でつまずくのは、その手前の話。
コードはsrc/lib/tools/exif.tsにある。fixtureのJPEGは置かず、テストは必要なバイト列をその場で組み立てている。壊れた入力の網羅もそのほうが楽だった。