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 のところに画像サイズが入っている。
という流れとは全く関係無いが、チョコフレークが食べたくなった。