デジタル写真の管理は長年の課題だ。多くの写真管理ソフトはデータベース技術に基づいており、有効活用するには分類や評価、注釈付けといった手間がかかる。専門的な編集機能を持つものもあるが、それはプロカメラマン向けだ。私は趣味としても写真を撮るわけではなく、子供の成長記録や家族のスナップ写真が中心だ。複雑な管理システムを使うのはコストに見合わない。
以前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))))
動作確認では正しく結合できた。しかし、使用をためらっている。ファイル構造は修復されるが、日付以外のデータは全くの偽装だからだ。見かけだけ整える行為に、どこか後ろめたさを感じる。