2012 03 04

またJPEG

コンビニのレジの横に募金箱が置いてあった。

この手の募金箱がたいてい透明なケースになっているのは、中にいくらか入っているのを見せることで募金を誘発することを狙っているからだろうか。 釣り銭が4円だったりしたときに目にすると、ちょっと募金したくなる時があるもんな。 ま、それは募金というよりは小銭の処分、むしろ 「捨てる」 に近い感覚なんだけど。

それにしても募金箱。 音だけだと、ちょっと凄い感じだな。 ボキン! バコ! って、ここに愛は無さそう。

+

いろいろ便利になったと噂の jdk1.7 で何か作ってみようと思い立って、以前作った ruby版Exif解析 を移植してみた。

構成は基本的に同じで、JFIF 用のクラスを定義して、これを継承して Exif 用のクラスを定義する。 また、それぞれの構成チェックのNGに対しては、専用の例外を投げることとする。

ということで、まずは JFIF 構成不正の例外。

public class JFIFFormatException extends Exception { public JFIFFormatException(String message) { super(message); } }

不正なマーカーとかを持たすことも考えたけど、要らないかなと思い直してこれだけ。

次に Exif 構成不正のための例外。

public class ExifFormatException extends JFIFFormatException { public ExifFormatException(String message) { super(message); } }

本体の処理との対称性があった方がいいような気がして JFIF 用の例外を継承する形にしたのだが、あんまり、と言うかほとんど意味が無いな。

本体の処理の前にもう一つ、バイト列を扱うための関数定義。

public class Util { public static int toInt(byte[] byteArray) { int i = 0; for (byte b : byteArray) { i = (i << 8) + (b & 0xff); } return i; } public static byte[] toByteArray(int i) { return new byte[] { (byte) (i >> 8), (byte) i }; } public static byte[] slice(byte[] sourceByteArray, int from, int length) { byte[] byteArray = new byte[length]; System.arraycopy(sourceByteArray, from, byteArray, 0, length); return byteArray; } }

そして本体の処理。 まずはJFIFから。

import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.util.HashMap; import java.util.Map; import java.util.Set; public class JFIF { public static enum Marker { SOI(0xFFD8), // Start Of Image SOS(0xFFDA), // Start Of Scan APP0(0xFFE0), // general parameters APP1(0xFFE1); // Exif public final int mark; Marker(int mark) { this.mark = mark; } } private final String fileName; private Map<Integer, byte[]> markers = new HashMap<>(); public JFIF(String fileName) { this.fileName = fileName; } public JFIF parse() throws JFIFFormatException, FileNotFoundException, IOException { try (FileInputStream st = new FileInputStream(fileName)) { if (Util.toInt(getByte(st, 2)) != Marker.SOI.mark) { throw new JFIFFormatException("not JFIF-JPEG"); } int mark; while ((mark = Util.toInt(getByte(st, 2))) != Marker.SOS.mark) { int size = Util.toInt(getByte(st, 2)); markers.put(mark, getByte(st, size - 2)); } } return this; } private static byte[] getByte(FileInputStream st, int length) throws IOException { byte[] b = new byte[length]; st.read(b); return b; } public Set<Integer> getAllMark() { return markers.keySet(); } public boolean hasMarker(int mark) { return markers.containsKey(mark); } public byte[] getMarker(int mark) { return markers.get(mark); } }

ファイル名を渡してインスタンスを生成する。 parse は、生成時に指定されたファイルを JFIF として解析し、結果を内部データとして格納する。 解析結果に対して、hasMarker でマーカーの有無を問い合わせ、getMarker でマーカーデータを取得する。

これを継承して Exif の処理。

import java.io.FileNotFoundException; import java.io.IOException; import java.util.Arrays; import java.util.HashMap; import java.util.Map; public class Exif extends JFIF { private static enum Endian { BIG, LITTLE; } private final static int TIFF_OFFSET = 6; private byte[] exifData; private Endian endian = Endian.BIG; private Map byteTags; private Map stringTags; private Map numberTags; private Map rationalTags; public Exif(String fileName) { super(fileName); } public Exif parse() throws JFIFFormatException, ExifFormatException, FileNotFoundException, IOException { super.parse(); clear(); parseExif(); return this; } private void clear() { exifData = null; byteTags = new HashMap<>(); stringTags = new HashMap<>(); numberTags = new HashMap<>(); rationalTags = new HashMap<>(); } private void parseExif() throws ExifFormatException, IOException { if (!hasMarker(Marker.APP1.mark)) { throw new ExifFormatException("not Exif"); } exifData = getMarker(Marker.APP1.mark); if (!Arrays.equals(getByteAsExif(0, 4), "Exif".getBytes()) || !Arrays.equals(getByteAsExif(4, 2), Util.toByteArray(0x0000))) { throw new ExifFormatException("not Exif"); } parseTiff(); } private void parseTiff() throws ExifFormatException { switch (new String(getByteAsTiff(0, 2))) { case "MM": endian = Endian.BIG; break; case "II": endian = Endian.LITTLE; break; default: throw new ExifFormatException("unsupported endian"); } if (!Arrays.equals(getByteAsTiff(2, 2), Util.toByteArray(0x002A))) { throw new ExifFormatException("not TIFF"); } parseIFD(8); if (numberTags.containsKey(34665)) { // Exif info parseIFD(numberTags.get(34665).intValue()); } if (numberTags.containsKey(34853)) { // GPS info parseIFD(numberTags.get(34853).intValue()); } } private void parseIFD(int offset) { int numberOfTags = Util.toInt(getByteAsTiff(offset, 2)); for (int i = 0; i < numberOfTags; i++) { parseTag(offset + 2 + i * 12); } } private void parseTag(int offset) { int tag = Util.toInt(getByteAsTiff(offset + 0, 2)); int type = Util.toInt(getByteAsTiff(offset + 2, 2)); int length = Util.toInt(getByteAsTiff(offset + 4, 4)); byte[] value = getByteAsTiff(offset + 8, 4); switch (type) { case 2: // ASCII stringTags.put(tag, getTagValueAsString(length, value)); break; case 3: // SHORT case 4: // LONG case 8: // SIGNED SHORT case 9: // SIGNED LONG numberTags.put(tag, getTagValueAsNumber(length, value)); break; case 5: // RATIONAL case 10: // SIGNED RATIONAL rationalTags.put(tag, getTagValueAsRational(length, value)); break; default: // 1:BYTE / 6;SIGNED BYTE / 7:UNDEFINED / 11:FLOAR / 12:DOUBLE byteTags.put(tag, getTagValueAsByte(length, value)); } } private byte[] getTagValueAsByte(int length, byte[] value) { if (length > 4) { return getByteAsTiff(Util.toInt(value), length, Endian.BIG); } else { return value; } } private String getTagValueAsString(int length, byte[] value) { if (length > 4) { return new String(getByteAsTiff(Util.toInt(value), length - 1, Endian.BIG)); } else { return new String(value); } } private long getTagValueAsNumber(int length, byte[] value) { return Util.toInt(value); } private long[] getTagValueAsRational(int length, byte[] value) { int offset = Util.toInt(value); return new long[] { Util.toInt(getByteAsTiff(offset, 4, Endian.BIG)), Util.toInt(getByteAsTiff(offset + 4, 4, Endian.BIG)) }; } private byte[] getByteAsExif(int from, int length) { return Util.slice(exifData, from, length); } private byte[] getByteAsTiff(int from, int length) { return getByteAsTiff(from, length, this.endian); } private byte[] getByteAsTiff(int from, int length, Endian endian) { byte[] data0 = getByteAsExif(TIFF_OFFSET + from, length); byte[] data1 = new byte[length]; if (endian == Endian.LITTLE) { for (int i = 0, j = length - 1; i < length; i++, j--) { data1[j] = data0[i]; } return data1; } else { return data0; } } public boolean hasByte(int tag) { return byteTags.containsKey(tag); } public boolean hasString(int tag) { return stringTags.containsKey(tag); } public boolean hasNumber(int tag) { return numberTags.containsKey(tag); } public boolean hasRational(int tag) { return rationalTags.containsKey(tag); } public byte[] getByte(int tag) { return byteTags.get(tag); } public String getString(int tag) { return stringTags.get(tag); } public long getNumber(int tag) { return numberTags.get(tag); } public long[] getRational(int tag) { return rationalTags.get(tag); } }

使い方は JFIF と同様で、ファイル名を指定してインスタンスを生成し、parse で解析する。 ちょっと違うのは、データ有無の問い合わせと取得を、データ型に応じたメソッドで行うこと。

最初は、タグを指定して、そのタグに対応する型と値のセットを返す単一メソッドを考えたのだが、本当に欲しい内容に対してコード量が無駄に増えそうなので止めた。 と言うか、眠いのに負けた。 怪しい数字が所々に残っているのも、小数の扱いが御座なりなのも、睡魔に負けたせい。

まあ、メソッドについては、タグに対応するデータの型は Exif の仕様で決まっている訳だし、何かの値が欲しいとき、その欲しいタグのデータ型が判らないってことも無いだろうし、型毎に分かれていてもいいよね。

ちなみに、今回本当に欲しかったのは写真の撮影日。 タグの番号で 36867 になる。

最後に Exif クラスの使用例。 画像ファイルのファイル名を撮影日にし、年月のディレクトリに整理する。

import java.io.File; import java.io.IOException; import java.nio.file.FileVisitResult; import java.nio.file.FileVisitor; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.SimpleFileVisitor; import java.nio.file.StandardCopyOption; import java.nio.file.attribute.BasicFileAttributes; import java.util.regex.Matcher; import java.util.regex.Pattern; public class JpegManager { private String outputRoot; public JpegManager(String outputRoot) { this.outputRoot = outputRoot; } private String toDirName(String dateTime) { String[] items = dateTime.split(" ")[0].split(":"); String target = outputRoot + "/" + items[0] + "/" + items[1]; try { Files.createDirectories(new File(target).toPath()); } catch (IOException e) { e.printStackTrace(); } return target; } private String toFileName(String dateTime) { return dateTime.replaceAll(":", "").replace(" ", "_") + ".jpg"; } public void searchFile(Path inputRoot) throws IOException { final Pattern pattern = Pattern.compile(".JPG$"); FileVisitor<Path> visitor = new SimpleFileVisitor<Path>() { @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { Matcher matcher = pattern.matcher(file.toString()); if (matcher.find()) { System.out.println(file); Exif exif = new Exif(file.toString()); try { exif.parse(); String dateTime = exif.getString(36867); Path target = new File(toDirName(dateTime) + "/" + toFileName(dateTime)).toPath(); Files.copy(file, target, StandardCopyOption.COPY_ATTRIBUTES, StandardCopyOption.REPLACE_EXISTING); } catch (Exception e) { e.printStackTrace(); } } return FileVisitResult.CONTINUE; } }; Files.walkFileTree(inputRoot, visitor); } public static void main(String[] args) { JpegManager jm = new JpegManager("D:/photo"); try { jm.searchFile(Paths.get("F:/temp")); } catch (Exception e) { e.printStackTrace(); } } }

名前を変更したファイルのコピー先のルートとなるパスを、コンストラクタで指定する。 で、対象となる画像ファイルのあるパスを指定してメソッド searchFile を呼ぶと、サブフォルダを含む指定したパス以下の jpeg ファイルを処理する。

コード中の値を使ってもうちょっと具体的に言うと、 F:/temp 以下、サブフォルダも含めて、拡張子が .JPG であるファイルを、その撮影日時を取り出して D:/photo/年/月/年月日_時分秒.jpg としてコピーする。 年月のフォルダは、無ければ作る。

さて、jdk1.7 だが、これまでと比べて確かに楽にはなっている。 楽にはなっているのだが、その進化がどうにも 「当社比」 という感じなんだよな。

try-with-resources で、くどくど解放処理を書かなくて済むようになった。 ファイルのコピーや移動が用意された。 でも、どっちも今更というか、むしろ遅過ぎ。 FileVisitor は便利だが、ruby や perl ならこの 1/3 ぐらいで書けそう。

それぞれに得意不得意があるし、あっていいんだけど、なんかこう、もうちょっと劇的に進化して欲しいものだよな。 まあ、だったら java を使うなよって話か。