固定辞書でEXIFを読むのは間違いだった ― 全走査に切り替えた設計判断

TL;DR
iOS向けのEXIF削除ツールを開発中、EXIF読み取りに「7つの固定辞書を指定して読む」という設計を採用した。AIがこの設計を忠実に実装し、動作確認も通った。
しかし実機テストで、カメラメーカー独自のメタデータ辞書(MakerNote, ExifAux, CIFF, 8BIM等)がごっそり読み落とされていることが判明した。
設計が間違っていれば、AIが正しく実装しても意味がない。
本記事では、固定辞書方式から全辞書走査方式への設計転換と、その過程で得た教訓を記録する。
最初の設計:7辞書固定
iOSのImageIOフレームワーク(CGImageSource)は、写真ファイルに含まれるメタデータを辞書単位で返す。最初の設計では、以下の7辞書を固定で参照していた。
kCGImagePropertyExifDictionary // EXIF
kCGImagePropertyTIFFDictionary // TIFF
kCGImagePropertyGPSDictionary // GPS
kCGImagePropertyIPTCDictionary // IPTC
kCGImagePropertyJFIFDictionary // JFIF
kCGImagePropertyPNGDictionary // PNG
kCGImagePropertyRawDictionary // RAW
この7つは、EXIF仕様で定義されている主要な辞書だ。これらを読み取り、カテゴリ別(位置情報・撮影日時・カメラ情報…)にUIに表示する、という設計。
AIにこの仕様を伝えたところ、きれいに実装してくれた。Swiftのネイティブコードは200行程度で、MethodChannel経由でFlutter側に辞書を渡す構成。
見た目上は完璧に動いていた。
問題の発見:読み落とし
実機テストのフェーズで、あることに気づいた。
手元のiPhoneで撮った写真と、一眼カメラで撮った写真を比較すると、一眼の方が明らかにメタデータの項目数が少ない。直感的には逆のはず。
調査してみると、一眼カメラの写真には以下のような辞書が含まれていた。
{MakerApple} // Apple独自のメーカーノート
{ExifAux} // 補助EXIF情報(レンズID等)
{CIFF} // Canon独自フォーマット
{8BIM} // Photoshop互換メタデータ
これらは先述の7辞書に含まれていないため、完全に無視されていた。
「主要な辞書を読めばカバーできる」という前提が間違っていた。
なぜ間違えたか
原因は3つある。
1. EXIF仕様書ベースで設計した
EXIF仕様(JEITA CP-3451等)に記載されている辞書は確かに上記の7種類が中心だ。しかし現実の写真ファイルには、仕様外のメタデータが大量に含まれている。カメラメーカーや画像編集ソフトが独自のデータを埋め込むためだ。
仕様書は「書いてあること」は正確だが、「書いていないこと」の存在を保証しない。
2. AIとの認識のズレ
「写真のメタデータを全部読む」という方針でAIに依頼したが、AIは「主要な7辞書を読む」という実装を返してきた。これは技術的に間違いではない。多くのEXIFライブラリがこのアプローチを取っている。
問題は、自分が「全部」と言った時の意味と、AIが「全部」と解釈した時の意味がズレていたことだ。自分は「ファイルに存在する全ての辞書」を意味していたが、AIは「EXIF仕様で定義されている全ての辞書」と解釈した。
3. テストデータの偏り
開発初期のテストには、iPhoneで撮った写真しか使っていなかった。iPhoneの写真に含まれるメタデータは比較的標準的で、7辞書でほぼカバーできてしまう。一眼カメラやPhotoshopで編集した写真を使っていれば、もっと早く気づけた。
全走査方式への転換
修正方針はシンプルだった。辞書を固定せず、ファイルに存在する全ての辞書を動的に走査する。
CGImageSourceはCGImageSourceCopyPropertiesAtIndexで全メタデータを辞書として返す。このトップレベル辞書のキーを列挙すれば、そのファイルに含まれる全ての辞書にアクセスできる。
// Before: 固定辞書を個別に取得
let exif = props[kCGImagePropertyExifDictionary] as? [String: Any]
let tiff = props[kCGImagePropertyTIFFDictionary] as? [String: Any]
let gps = props[kCGImagePropertyGPSDictionary] as? [String: Any]
// ... 7つ分
// After: 全キーを走査
for (key, value) in properties {
if let dict = value as? [String: Any] {
// 辞書なら再帰的に読み取り
}
}
概念としては単純だが、実装上は2つの課題があった。
課題1:型の正規化
CGImageSourceが返すメタデータには、NSNumber、NSArray、NSDictionary、CFBoolean等、様々な型が混在する。FlutterのMethodChannelはこれらを直接受け取れないため、再帰的に正規化するsanitize()関数を書く必要があった。
func sanitize(_ value: Any) -> Any? {
switch value {
case let dict as [String: Any]:
return dict.compactMapValues { sanitize($0) }
case let array as [Any]:
return array.compactMap { sanitize($0) }
case let number as NSNumber:
// CFBoolean判定 → Bool, それ以外 → Int or Double
...
default:
return String(describing: value)
}
}
課題2:カテゴリ分類
固定辞書方式では、辞書名がそのままカテゴリだった(GPS辞書 → 位置情報、TIFF辞書 → カメラ情報…)。全走査方式では、未知の辞書が来るため、フィールド名の意味に基づいてカテゴリを判定する必要がある。
最初は「どのカテゴリにも分類できないフィールドは『その他』に入れる」という方針だった。しかしテストしてみると、「その他」に大量のフィールドが溜まり、ユーザーにとって何の価値もない分類になった。
「その他」カテゴリは、分類の設計が不十分であることの証拠。
最終的に、4つのルールで意味ベースの分類に統一した。
// ルール1: GPSプレフィックス → 位置情報
if (key.startsWith('GPS')) return ExifCategory.location;
// ルール2: Maker* / CIFF_ / ExifAux_ → デバイス情報
if (key.startsWith('Maker') || ...) return ExifCategory.device;
// ルール3: IPTC_ / 8BIM_ → 著作権
if (key.startsWith('IPTC') || ...) return ExifCategory.copyright;
// ルール4: その他全て → 画像技術情報
return ExifCategory.imageTech;
「その他」を廃止し、未知のフィールドは全て「画像技術情報」に分類する。ユーザーが見て意味のあるカテゴリ名になった。
debug_fallback:開発中はクラッシュさせろ
全走査への移行中、もう一つ重要な設計判断を行った。フォールバック処理の扱いだ。
EXIF読み取りで想定外の値が来た場合、最初の実装ではフォールバック(デフォルト値を返す)で処理していた。アプリは止まらないが、想定外の状態が静かに握りつぶされる。
これが実は危険だった。GPSの緯度経度を解析する処理で、参照値(N/S, E/W)が欠落している写真が存在した。フォールバックで座標0を返していたが、本来は「GPS情報なし」として扱うべきだった。
対策として、2つのフォールバックレベルを導入した。
// 開発中:クラッシュさせて気づく
void criticalFallback(String message) {
assert(false, message); // Debug: 即死
// Release: ログ出力して処理継続
}
// 許容できるフォールバック
void warnFallback(String message) {
debugPrint('[WARN] $message');
// 処理継続(ただしログには残す)
}
開発中のフォールバックは「想定外の検知ポイント」であるべきで、「それっぽく動かすための装置」であってはならない。
リリースビルドではクラッシュさせないが、デバッグビルドでは即座に落とす。これにより、「動いているように見えるが実は間違っている」という最悪の状態を防げた。
AIとの認識ズレの本質
この設計転換のプロセスで、AIとの協業における重要な教訓を得た。
全走査に切り替える方針でAIと合意した後、生成されたコードを確認すると、辞書の走査は全走査になっているのに、カテゴリ分類はまだプレフィックスベースのままだった。
つまり、
- 方針レベル:「全辞書を走査する」(合意済み)
- 実装レベル:「プレフィックスで分類する」(旧ロジックが残存)
この矛盾に気づくまでに数往復かかった。
AIは方針変更を「それが影響する範囲」まで自発的に追跡しない。 「辞書走査を変える」と言われたら辞書走査のコードを変えるが、それに依存する下流の分類ロジックまで自発的には修正しない。
人間が「方針変更の影響範囲」を明示的に伝えるか、変更後にシステム全体の整合性を検証する必要がある。
まとめ:設計の誤りはコードの正しさで補えない
| 項目 | 固定辞書方式 | 全走査方式 |
|---|---|---|
| 走査対象 | 7辞書固定 | ファイル内の全辞書 |
| メーカー独自タグ | 読み落とし | 全て取得 |
| カテゴリ分類 | 辞書名ベース | 意味ベース(4ルール) |
| 「その他」 | あり(ゴミ箱化) | なし |
| 未知のフォーマット | 無視 | 画像技術情報に分類 |
この設計転換から得た教訓は3つ。
- 仕様書ベースの設計は「既知の範囲」しかカバーしない。 実データのバリエーションは仕様を超える。
- AIが正しく実装しても、設計が間違っていれば全部ズレる。 「仕様理解」は人間の仕事。
- 「その他」カテゴリは設計の妥協。 「分類できないもの」が存在すること自体が、分類基準の不備を示している。
次に同じことをやる人への一言
メタデータを扱うアプリを作るなら、最初から「想定外のデータが来る」前提で設計しろ。 固定リストで対処しようとした時点で、そのリストは必ず不完全になる。
そして、AIに設計を任せるな。AIは与えられた設計を忠実に、高速に実装してくれる。だからこそ、設計が間違っていると、間違いも高速に量産される。
設計は人間の仕事だ。
Discussion