2012 01 17

Exifの撮影日

父の撮り溜めた写真がiPadに大量にある。 これらはみな撮影日毎に自動整理されていたのだが、先日のiOSアップグレードの時に問題があったようで、一部で撮影日が表示されなくなってしまった。

で、再度取り込み直そうとして、ふと考える。 画像ファイルのファイル名って、デジカメの内部のカウンターをつかった連番で構成されているので、見ただけじゃいつ撮ったものか判らない。 これが、撮影日がファイル名になってたら便利なんじゃないだろうか。 年月日_時分秒 で 20120117_120005.jpg みたいな感じで。

ということで、しばらく前に作った JPEGファイルから画像サイズを取り出すもの を弄って作ってみた。

# byte_array.rb module ByteArray def toHexString( byte_array ) byte_array.unpack( 'H*' ).join end def toInteger( byte_array ) toHexString( byte_array ).hex end end

まずは、これでいいのか微妙なバイト列用のモジュール。 バイト列を16進文字列に変換する処理と、それを更に数値に変換する処理。

# jfif.rb require 'byte_array' class JFIF include ByteArray @filename @filetype @markers def initialize( filename, filetype = 'JFIF' ) @filename = filename @filetype = filetype @markers = {} parseJFIF end def parseJFIF File.open( @filename, 'rb' ) do | f | raise 'not JPEG' unless toHexString( f.read( 2 ) ) == 'ffd8' until ( m = get_marker( f ) )[ :mark ] == 'ffda' @markers[ m[ :mark ] ] = m[ :data ] end end if @filetype == 'JFIF' raise 'not JFIF' unless app0 = @markers[ 'ffe0' ] raise 'not JFIF' unless app0[ 0 .. 3 ] == 'JFIF' end end private :parseJFIF def get_marker( f ) m = {} m[ :mark ] = toHexString( f.read( 2 ) ) m[ :size ] = toInteger( f.read( 2 ) ) m[ :data ] = f.read( m[ :size ] - 2 ) m end private :get_marker def marks @markers.keys end def marker( mark ) @markers[ mark ] end end

次に、Exif の元である JFIF 形式のデータ解析処理。

わざわざ JFIF を経ないで直接 Exif を取り出してもいいのだが、これはこれでいつかきっと使うだろうから。 いや、そもそも画像サイズの取り出しのために作ったのが元だから、既に使っているのか。

以下、コード中に出てきている値の説明。

ffd8

JPEG ファイルは、先頭が必ずこの値になっているので、これを使ってファイルが JPEG かどうかを確認している。

ffda

画像そのもののデータの始まりを示すマーカー。

各種データはここまでに設定されていて、これ以降にはイメージデータしかない。 はず。

ffe0

APP0 のマーカー。

JFIF では、最初のマーカーとして app0 が設定されることになっていて、このマーカーの先頭には 'JFIF' が設定されることになっている。 なので、これを使って画像が JFIF であることを確認している。

#exif.rb require 'fileutils' require 'jfif' class Exif < JFIF include ByteArray BIG_ENDIAN = 'MM' LITTLE_ENDIAN = 'II' TIFF_OFFSET = 6 @app1 @endian @tags def initialize( filename, filetype = 'EXIF' ) super( filename, filetype ) @tags = {} parseExif end def parseExif raise 'not Exif' unless @app1 = @markers[ 'ffe1' ] raise 'not Exif' unless @app1[ 0 .. 3 ] == 'Exif' parseTiff end private :parseExif def parseTiff @endian = slice( 0, 2 ) raise 'unsupported endian' unless @endian == BIG_ENDIAN || @endian == LITTLE_ENDIAN raise 'not TIFF' unless toHexString( slice( 2, 2, @endian ) ) == '002a' parseIFD( 8 ) parseIFD( @tags[ 34665 ][ :value ] ) if @tags[ 34665 ] # 34665 : Exif IFD Pointer parseIFD( @tags[ 34853 ][ :value ] ) if @tags[ 34853 ] # 34845 : GPS info IFD Pointer end private :parseTiff def parseIFD( offset ) n = toInteger( slice( offset, 2, @endian ) ) i = 0 while i < n tag = parseTag( offset + 2 + i * 12 ) @tags[ tag[ :tag ] ] = tag i += 1 end end private :parseIFD def parseTag( offset ) tag = {} tag[ :tag ] = toInteger( slice( offset + 0, 2, @endian ) ) tag[ :type ] = type = toInteger( slice( offset + 2, 2, @endian ) ) tag[ :size ] = size = toInteger( slice( offset + 4, 4, @endian ) ) tag[ :value ] = value = slice( offset + 8, 4, @endian ) case type when 2 then # ASCII tag[ :value ] = slice( toInteger( value ), size - 1 ) if size > 4 # 末尾の0を削除 when 3, # SHORT : 2byte 4, # LONG : 4byte 9 then # Signed LONG : 4byte tag[ :value ] = toInteger( value ) when 5, # RATIONAL : 4byte * 2 10 then # Signed RATIONAL : 4byte * 2 value_offset = toInteger( value ) tag[ :value ] = [ toInteger( slice( value_offset, 4 ) ), toInteger( slice( value_offset + 4, 4 ) ) ] else # BYTE, etc tag[ :value ] = slice( toInteger( value ), size ) if size > 4 end tag end private :parseTag def slice( from, length, endian = BIG_ENDIAN ) data = @app1[ TIFF_OFFSET + from .. TIFF_OFFSET + from + length - 1 ] if endian == LITTLE_ENDIAN data.unpack( 'C*' ).reverse.pack( 'C*' ) else data end end private :slice def get( tag ) @tags[ tag ] end end

そしてやっと Exif の処理。 JFIF の派生クラスとして実装している。

以下、コード中に出てきている値の説明など。

ffe1

APP1 のマーカー。

JFIF は APP0 だったが、Exif の場合は APP0 の替わりに APP1 が来ることになっている。 で、このデータの先頭に 'Exif' が設定されることになっている。

'MM' / 'II'

エンディアンの指定。 MM がビッグエンディアンで、II がリトルエンディアン。

Exif は、データの細目を TIFF 形式で持っていて、エンディアンはこのデータの並び順を決める。 上記 Exif に続いて設定されることになっている。

リトルエンディアンだと、読み込んだデータをひっくり返す必要があって地味に面倒臭い。 俺が面倒だと思うんだから、デジカメメーカーの人もきっとそう思うだろう。 リトルエンディアンのデータなんてきっと無い。 問答無用にビッグエンディアンでいいだろう。 そう決めつけて作ったら、コニカミノルタの画像ファイルがリトルエンディアンだった。 ガッカリだよ。 そんなことだから経営不振になるのだ。

002a

TIFF マーク

データが TIFF 形式であることを示す。 わざわざこんなマークを設定するのは、TIFF ではない場合もあるからだろう。 でも、手元にある画像データは全て TIFF 形式だったので、他の形式は考慮しない。

これを使って、画像ファイルから撮影日時を取り出し、ファイル名を変更する。

撮影日時は、Exif 内のタグ 36867(0x9003) のデータとして、 YYYY:MM:DD HH:MM:SS の書式で記録されている。 この値を取り出して、折角だから 年/月/日 とディレクトリを階層化し、そこに画像ファイルを、日時に名前を変更してコピーする。

require 'exif' include FileUtils Dir.glob( "/Users/watanabe/Pictures/photo/*/*.JPG" ).each do | f | begin exif = Exif.new( f ) dateTime = exif.get( 36867 )[ :value ] dirTree = dateTime.split( / / )[0].split( /:/ ) dirname = "/Users/watanabe/Pictures/photo2/#{ dirTree[0] }/#{ dirTree[1] }/#{ dirTree[2] }" newFilename = dateTime.gsub( /:/, "" ).gsub( / /, "_" ) + ".jpg" newPath = "#{ dirname }/#{ newFilename }" FileUtils.mkdir_p dirname FileUtils.cp f, newPath rescue => e p e p "err : #{ f }" end end

これがその処理のコードなのだが、肝心の Exif のデータ解析と日時の取得部分は

exif = Exif.new( f ) dateTime = exif.get( 36867 )[ :value ]

と、たったの2行。 いくつか試してみた所、36867という値は重複も無さそうだし、この程度だったら TIFF の解析なんかしないで直接 36867 を探して撮影日時を取得しても良かったのかもしれない。

折角なので JFIF 側の使用例も書いておこう。 前に作ったのと同じ、画像サイズ(解像度)を取得して表示するならこんな感じ。

require 'jfif' include ByteArray Dir.glob( "/Users/watanabe/Pictures/*/*.jpg" ).each do | f | jfif = JFIF.new( f ) jfif.marks.each do | mark | marker = jfif.marker( mark ) if marker[ 0 ] == 8 h = toInteger( marker[ 1 .. 2 ] ) w = toInteger( marker[ 3 .. 4 ] ) p "#{ f } : #{ w } x #{ h }" break end end end

これまた TIFF からデータを取り出す処理は2行。 あと、8 がなんなのかは未だに不明。 何だか判らないが、JFIF ではマーカーデータの先頭が 8 のところに画像サイズが入っている。

という流れとは全く関係無いが、チョコフレークが食べたくなった。