🤖

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

に公開

TL;DR

iOS向けのEXIF削除ツールを開発中、EXIF読み取りに「7つの固定辞書を指定して読む」という設計を採用した。AIがこの設計を忠実に実装し、動作確認も通った。

しかし実機テストで、カメラメーカー独自のメタデータ辞書(MakerNote, ExifAux, CIFF, 8BIM等)がごっそり読み落とされていることが判明した。

設計が間違っていれば、AIが正しく実装しても意味がない。

本記事では、固定辞書方式から全辞書走査方式への設計転換と、その過程で得た教訓を記録する。

https://exif-eraser-app.web.app/


最初の設計: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つ。

  1. 仕様書ベースの設計は「既知の範囲」しかカバーしない。 実データのバリエーションは仕様を超える。
  2. AIが正しく実装しても、設計が間違っていれば全部ズレる。 「仕様理解」は人間の仕事。
  3. 「その他」カテゴリは設計の妥協。 「分類できないもの」が存在すること自体が、分類基準の不備を示している。

次に同じことをやる人への一言

メタデータを扱うアプリを作るなら、最初から「想定外のデータが来る」前提で設計しろ。 固定リストで対処しようとした時点で、そのリストは必ず不完全になる。

そして、AIに設計を任せるな。AIは与えられた設計を忠実に、高速に実装してくれる。だからこそ、設計が間違っていると、間違いも高速に量産される。

設計は人間の仕事だ。

Discussion