父の撮り溜めた写真が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 を取り出してもいいのだが、これはこれでいつかきっと使うだろうから。 いや、そもそも画像サイズの取り出しのために作ったのが元だから、既に使っているのか。
以下、コード中に出てきている値の説明。
JPEG ファイルは、先頭が必ずこの値になっているので、これを使ってファイルが JPEG かどうかを確認している。
画像そのもののデータの始まりを示すマーカー。
各種データはここまでに設定されていて、これ以降にはイメージデータしかない。 はず。
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 の派生クラスとして実装している。
以下、コード中に出てきている値の説明など。
APP1 のマーカー。
JFIF は APP0 だったが、Exif の場合は APP0 の替わりに APP1 が来ることになっている。 で、このデータの先頭に 'Exif' が設定されることになっている。
エンディアンの指定。 MM がビッグエンディアンで、II がリトルエンディアン。
Exif は、データの細目を TIFF 形式で持っていて、エンディアンはこのデータの並び順を決める。 上記 Exif に続いて設定されることになっている。
リトルエンディアンだと、読み込んだデータをひっくり返す必要があって地味に面倒臭い。 俺が面倒だと思うんだから、デジカメメーカーの人もきっとそう思うだろう。 リトルエンディアンのデータなんてきっと無い。 問答無用にビッグエンディアンでいいだろう。 そう決めつけて作ったら、コニカミノルタの画像ファイルがリトルエンディアンだった。 ガッカリだよ。 そんなことだから経営不振になるのだ。
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 のところに画像サイズが入っている。
という流れとは全く関係無いが、チョコフレークが食べたくなった。