Exif情報を活用した写真整理と修復の自動化

デジタル写真の管理は長年の課題だ。多くの写真管理ソフトはデータベース技術に基づいており、有効活用するには分類や評価、注釈付けといった手間がかかる。専門的な編集機能を持つものもあるが、それはプロカメラマン向けだ。私は趣味としても写真を撮るわけではなく、子供の成長記録や家族のスナップ写真が中心だ。複雑な管理システムを使うのはコストに見合わない。

以前Ubuntuを使っていた頃、Gnomeに標準搭載されていた写真管理アプリケーションを利用した経験がある。名前は忘れたが、写真をインポートする際、Exifの撮影日時を読み取り、「prefix/2000/01/01」のように年月日でディレクトリを自動作成して保存してくれた。この機能が非常に便利だと感じた。特別な管理ソフトを使わなくても、ファイルシステム上で時系列に整理できるからだ。Gnomeを使わなくなってからも、この方式で手動管理を続けてきた。

しかし最近、限界を感じるようになった。スマホ内の写真が増え続け、どれをPCに取り込み済みか把握できなくなった。デジタルカメラの写真も同様だ。さらに、カメラのファイル名には撮影日が含まれないため、Exifを確認しなければ適切なディレクトリに配置できない。これは骨の折れる作業だ。Pythonのスクリプトを見かけたことをきっかけに、Exif処理を自作することにした。

RacketでExifライブラリを書こうと決意した。写真インポート用のスクリプトに使うだけでなく、将来的に他の場面でも役立つかもしれない。だがExifは柔軟かつ複雑だ。JPGファイルにはExifセグメントやJFIFセグメントが存在する場合もあれば、両方ある場合もある。Exif内部ではTIFF形式で情報を格納し、TIFF自体もエンディアンの問題を抱えている。TIFFはIFDで情報のカテゴリを管理し、IFDは可変で子IFDを持つこともある。同じタグが異なるIFDに存在し、値も異なる場合がある。どのIFDから値を取得すべきか。IFDに一意のIDをどう付与するか。線形構造、ツリー構造、ハッシュテーブル、どれもしっくりこない。300行程度のコードだが、何度も書き直した。写真インポート用スクリプトは動くものの、Exifライブラリのバグで頻繁にクラッシュした。TIFFの有理数で分母が0になるという異常なケースにも遭遇した。

最終的に、包括的なExif読み書きライブラリの構築は諦めた。必要なのは写真から日付を取得することだけだ。正規表現で十分だと気づいた。

#!/usr/bin/env racket
#lang racket/base

(require racket/path)
(require racket/file)
(require racket/string)

(define LOG_ERROR "import_errors.log")
(define LOG_SUCCESS "import_history.log")

(define (log-error msg)
  (display msg)
  (display-to-file msg LOG_ERROR #:mode 'text #:exists 'append))

(define (log-success msg)
  (display msg)
  (display-to-file msg LOG_SUCCESS #:mode 'text #:exists 'append))

(define (read-2bytes port)
  (let ((high (read-byte port)))
    (+ (* high 256) (read-byte port))))

(define (extract-date-via-regex pattern filepath)
  (define (scan-exif-segment port)
    (let ((marker (read-2bytes port)))
      (let ((seg-size (read-2bytes port)))
        (cond
          ((not (<= #xffe0 marker #xffef)) #f)
          ((= marker #xffe1)
           (let* ((tiff-data (read-bytes (- seg-size 8) port))
                  (match-result (regexp-match pattern tiff-data)))
             (if match-result
                 (map bytes->string/latin-1 match-result)
                 #f)))
          (else
           (read-bytes (- seg-size 2) port)
           (scan-exif-segment port))))))
  
  (call-with-input-file filepath
    (lambda (port)
      (let ((soi-marker (read-2bytes port)))
        (if (not (= soi-marker #xffd8))
            #f
            (scan-exif-segment port))))))

(define (extract-date-from-filename pattern filepath)
  (regexp-match pattern (path->string (file-name-from-path filepath))))

(define (resolve-photo-date filepath)
  (let ((date-regex (pregexp "(19[89]\\d|20[012]\\d)\\D?(0[1-9]|1[0-2])\\D?(0[1-9]|[12]\\d|3[01])")))
    (let ((result (or (extract-date-via-regex date-regex filepath)
                      (extract-date-from-filename date-regex filepath))))
      (if result
          (string-join (cdr result) "/")
          #f))))

(define (process-photo-file source-file target-root)
  (let ((date-path (resolve-photo-date source-file)))
    (if date-path
        (let* ((target-dir (string-append target-root "/" date-path))
               (dest-file (string-append target-dir "/" 
                                        (path->string (file-name-from-path source-file)))))
          (if (file-exists? dest-file)
              (log-success (format "[SKIP] File exists: ~a\n" dest-file))
              (begin
                (make-directory* target-dir)
                (copy-file source-file dest-file)
                (log-success (format "[OK] ~a -> ~a\n" source-file dest-file)))))
        (log-error (format "[ERR] Date unknown: ~a\n" source-file)))))

(define (is-jpeg-file? path)
  (let ((ext (path-get-extension path)))
    (member ext '(#".jpg" #".jpeg" #".JPG" #".JPEG"))))

(define (file-processor path type accumulator)
  (when (and (eq? type 'file) (is-jpeg-file? path))
    (process-photo-file path accumulator))
  accumulator)

(define (run-import source-dir dest-dir)
  (fold-files file-processor dest-dir source-dir))

(define (show-usage)
  (printf "\nPhoto Importer - Organize JPEG files by date from Exif or filename.\n
Usage: ~a [source-dir] <dest-dir>

Reads date from Exif metadata or filename, creates yyyy/mm/dd directory structure.
Source-dir is optional (defaults to current directory).

Logs: ~a (errors), ~a (successful imports)\n\n"
          (file-name-from-path (find-system-path 'run-file))
          LOG_ERROR
          LOG_SUCCESS))

(let ((args (vector->list (current-command-line-arguments))))
  (if (null? args)
      (show-usage)
      (let ((src (if (= (length args) 1)
                     (current-directory)
                     (car args)))
            (dest (if (= (length args) 1)
                      (car args)
                      (cadr args))))
        (cond
          ((not (directory-exists? src))
           (printf "Error: Source directory not found: ~a\n" src))
          ((not (directory-exists? dest))
           (printf "Error: Destination directory not found: ~a\n" dest))
          (else
           (when (file-exists? LOG_ERROR) (delete-file LOG_ERROR))
           (when (file-exists? LOG_SUCCESS) (delete-file LOG_SUCCESS))
           (run-import src dest))))))

このスクリプトの安定性は劇的に向上した。

使用中に、多くの写真でExifが破損していることが判明した。以前使用していた写真管理ソフトが、Exifを編集可能だと勝手に判断し、機能を提供していたのだ。その編集結果がExifを壊していた。数十GBの写真ライブラリを整理しようとして、Exif欠損の多さに気づいた時には遅かった。失われたデータは二度と戻らない。Exif編集機能には慎重であるべきだ。メタデータは本来読み取り専用であり、場合によっては法的な証拠になり得るものだ。安易に編集すべきではない。

問題は発生してしまった。幸い、私は時系列で整理していたため、Exifがなくても撮影日を特定できる。破損したExif構造を修復する方法はないか。失われたデータは復元できないが、構造の整合性だけでも確保したい。そこで、同一機種で撮影した正常な写真のExifを抽出し、破損した写真に移植することを思いついた。日付情報だけ正規表現で書き換えれば、複雑なタグ編集を避けられる。

#!/usr/bin/env racket
#lang racket/base

(require racket/path)
(require racket/port)

(define MARKER_SOS #xffda)
(define MARKER_EXIF #xffe1)

(define (read-2bytes port)
  (let ((high (read-byte port)))
    (+ (* high 256) (read-byte port))))

(define (locate-segment filepath marker-type)
  (define (scan-markers port)
    (let ((marker (read-2bytes port)))
      (let ((seg-size (read-2bytes port)))
        (cond
          ((= marker marker-type)
           (let* ((seg-start (- (file-position port) 2))
                  (seg-end (+ seg-start seg-size)))
             (cons seg-start seg-end)))
          (else
           (read-bytes (- seg-size 2) port)
           (scan-markers port))))))
  
  (call-with-input-file filepath
    (lambda (port)
      (let ((soi (read-2bytes port)))
        (if (not (= soi #xffd8))
            #f
            (scan-markers port))))))

(define (patch-exif-segment template-file target-file output-file new-date)
  (let ((template-range (locate-segment template-file MARKER_EXIF))
        (target-range (locate-segment target-file MARKER_EXIF)))
    (call-with-output-file output-file
      (lambda (out-port)
        (call-with-input-file target-file
          (lambda (target-in)
            (call-with-input-file template-file
              (lambda (template-in)
                (let* ((exif-data (modify-date (read-bytes (cdr template-range) template-in) new-date))
                       (_ (read-bytes (cdr target-range) target-in))
                       (image-data (port->bytes target-in)))
                  (write-bytes exif-data out-port)
                  (write-bytes image-data out-port))))))
      #:exists 'replace)))

(define (modify-date byte-vector date-string)
  (let ((date-pattern (pregexp "[12]\\d\\d\\d:[01]\\d:[0-3]\\d")))
    (regexp-replace* date-pattern byte-vector date-string)))

(let ((args (vector->list (current-command-line-arguments))))
  (if (< (length args) 4)
      (printf "Usage: ~a <template.jpg> <damaged.jpg> <output.jpg> <YYYY:MM:DD>\n"
              (file-name-from-path (find-system-path 'run-file)))
      (patch-exif-segment (car args) (cadr args) (caddr args) (cadddr args))))

動作確認では正しく結合できた。しかし、使用をためらっている。ファイル構造は修復されるが、日付以外のデータは全くの偽装だからだ。見かけだけ整える行為に、どこか後ろめたさを感じる。

タグ: EXIF JPEG 画像管理 Racket 正規表現

7月18日 23:08 投稿