001/*
002 * cilla - Blog Management System
003 *
004 * Copyright (C) 2012 Richard "Shred" Körber
005 *   http://cilla.shredzone.org
006 *
007 * This program is free software: you can redistribute it and/or modify
008 * it under the terms of the GNU Affero General Public License as published
009 * by the Free Software Foundation, either version 3 of the License, or
010 * (at your option) any later version.
011 *
012 * This program is distributed in the hope that it will be useful,
013 * but WITHOUT ANY WARRANTY; without even the implied warranty of
014 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
015 * GNU General Public License for more details.
016 *
017 * You should have received a copy of the GNU Affero General Public License
018 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
019 */
020package org.shredzone.cilla.service.resource;
021
022import java.io.File;
023import java.io.IOException;
024import java.io.InputStream;
025import java.math.BigDecimal;
026import java.math.RoundingMode;
027import java.util.Date;
028import java.util.Locale;
029import java.util.TimeZone;
030
031import org.shredzone.cilla.core.model.embed.ExifData;
032import org.shredzone.cilla.core.model.embed.Geolocation;
033import org.slf4j.Logger;
034import org.slf4j.LoggerFactory;
035
036import com.drew.imaging.PhotographicConversions;
037import com.drew.imaging.jpeg.JpegMetadataReader;
038import com.drew.imaging.jpeg.JpegProcessingException;
039import com.drew.lang.Rational;
040import com.drew.metadata.Directory;
041import com.drew.metadata.Metadata;
042import com.drew.metadata.MetadataException;
043import com.drew.metadata.exif.CanonMakernoteDirectory;
044import com.drew.metadata.exif.CasioType2MakernoteDirectory;
045import com.drew.metadata.exif.ExifIFD0Directory;
046import com.drew.metadata.exif.ExifSubIFDDirectory;
047import com.drew.metadata.exif.GpsDirectory;
048import com.drew.metadata.exif.PentaxMakernoteDirectory;
049import com.drew.metadata.xmp.XmpDirectory;
050
051/**
052 * Analyzes the EXIF and GPS information of a JPEG image. It's a wrapper around the
053 * Metadata Extractor of Drew Noakes.
054 *
055 * @author Richard "Shred" Körber
056 * @see <a href="http://drewnoakes.com/code/exif/">Metadata Extractor</a>
057 */
058public class ExifAnalyzer {
059    private final Logger log = LoggerFactory.getLogger(getClass());
060
061    private final Metadata metadata;
062
063    /**
064     * Creates a new {@link ExifAnalyzer} for the given {@link Metadata}.
065     * <p>
066     * Private, use the factory classes instead.
067     *
068     * @param metadata
069     *            {@link Metadata} to analyze
070     */
071    private ExifAnalyzer(Metadata metadata) {
072        this.metadata = metadata;
073    }
074
075    /**
076     * Creates a new {@link ExifAnalyzer} for the given JPEG file.
077     *
078     * @param file
079     *            JPEG file to analyze
080     * @return {@link ExifAnalyzer} or {@code null} if it is no valid JPEG image.
081     */
082    public static ExifAnalyzer create(File file) throws IOException {
083        try {
084            return new ExifAnalyzer(JpegMetadataReader.readMetadata(file));
085        } catch (JpegProcessingException ex) {
086            LoggerFactory.getLogger(ExifAnalyzer.class)
087                    .debug("Could not analyze file " + file.getName(), ex);
088            return null;
089        }
090    }
091
092    /**
093     * Creates a new {@link ExifAnalyzer} for the given JPEG input stream.
094     *
095     * @param in
096     *            JPEG input stream to analyze
097     * @return {@link ExifAnalyzer} or {@code null} if it is no valid JPEG image.
098     */
099    public static ExifAnalyzer create(InputStream in) throws IOException {
100        try {
101            return new ExifAnalyzer(JpegMetadataReader.readMetadata(in));
102        } catch (JpegProcessingException ex) {
103            LoggerFactory.getLogger(ExifAnalyzer.class)
104                    .debug("Could not analyze input stream", ex);
105            return null;
106        }
107    }
108
109    /**
110     * Gets the Metadata containing the EXIF information.
111     *
112     * @return Metadata
113     */
114    public Metadata getMetadata() {
115        return metadata;
116    }
117
118    /**
119     * Gets the {@link ExifData} of the picture taken.
120     *
121     * @return {@link ExifData}, never {@code null}, but it could be empty.
122     */
123    public ExifData getExifData() {
124        ExifData exif = new ExifData();
125
126        exif.setCameraModel(getCameraModel());
127        exif.setAperture(getAperture());
128        exif.setShutter(getShutter());
129        exif.setIso(getIso());
130        exif.setExposureBias(getExposureBias());
131        exif.setFocalLength(getFocalLength());
132        exif.setFlash(getFlash());
133        exif.setWhiteBalance(getWhiteBalance());
134        exif.setMeteringMode(getMeteringMode());
135        exif.setFocusMode(getFocusMode());
136        exif.setProgram(getProgram());
137
138        return exif;
139    }
140
141    /**
142     * Gets the {@link Geolocation} where the picture was taken.
143     *
144     * @return {@link Geolocation}, never {@code null}, but it could be empty.
145     */
146    public Geolocation getGeolocation() {
147        Geolocation location = new Geolocation();
148
149        BigDecimal longitude = readAngle(GpsDirectory.class, GpsDirectory.TAG_GPS_LONGITUDE);
150        if (longitude != null) {
151            String longRef = readString(GpsDirectory.class, GpsDirectory.TAG_GPS_LONGITUDE_REF);
152            if ("W".equals(longRef)) {
153                longitude = longitude.negate();
154            }
155            location.setLongitude(longitude);
156        }
157
158        BigDecimal latitude = readAngle(GpsDirectory.class, GpsDirectory.TAG_GPS_LATITUDE);
159        if (latitude != null) {
160            String latRef = readString(GpsDirectory.class, GpsDirectory.TAG_GPS_LATITUDE_REF);
161            if ("S".equals(latRef)) {
162                latitude = latitude.negate();
163            }
164            location.setLatitude(latitude);
165        }
166
167        Rational altitude = readRational(GpsDirectory.class, GpsDirectory.TAG_GPS_ALTITUDE);
168        if (altitude != null) {
169            BigDecimal altDec = new BigDecimal(altitude.doubleValue()).setScale(3, RoundingMode.HALF_DOWN);
170
171            String altRef = readString(GpsDirectory.class, GpsDirectory.TAG_GPS_ALTITUDE_REF);
172            if ("1".equals(altRef)) {
173                altDec = altDec.negate();
174            }
175            location.setAltitude(altDec);
176        }
177
178        return location;
179    }
180
181    /**
182     * Gets the date and time when the picture was taken according to the EXIF data.
183     *
184     * @param tz
185     *         The camera's TimeZone
186     * @return Date and time, or {@code null} if the information could not be retrieved
187     */
188    public Date getDateTime(TimeZone tz) {
189        Date date = null;
190
191        date = readDate(ExifSubIFDDirectory.class, ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL, tz);
192
193        if (date == null) {
194            date = readDate(ExifSubIFDDirectory.class, ExifSubIFDDirectory.TAG_DATETIME_DIGITIZED, tz);
195        }
196
197        if (date == null) {
198            date = readDate(ExifIFD0Directory.class, ExifIFD0Directory.TAG_DATETIME, tz);
199        }
200
201        if (date == null) {
202            date = readDate(XmpDirectory.class, XmpDirectory.TAG_DATETIME_ORIGINAL, tz);
203        }
204
205        if (date == null) {
206            date = readDate(XmpDirectory.class, XmpDirectory.TAG_DATETIME_DIGITIZED, tz);
207        }
208
209        return date;
210    }
211
212    /**
213     * Gets the Camera Model.
214     *
215     * @return Camera Model string, or {@code null} if the information could not be
216     *         retrieved
217     */
218    public String getCameraModel() {
219        String model = readString(ExifIFD0Directory.class, ExifIFD0Directory.TAG_MODEL);
220        if (model != null) {
221            return model;
222        }
223
224        model = readString(ExifIFD0Directory.class, ExifIFD0Directory.TAG_MAKE);
225        if (model != null) {
226            return model;
227        }
228
229        model = readString(XmpDirectory.class, XmpDirectory.TAG_MODEL);
230        if (model != null) {
231            return model;
232        }
233
234        model = readString(XmpDirectory.class, XmpDirectory.TAG_MAKE);
235        if (model != null) {
236            return model;
237        }
238
239        return null;
240    }
241
242    /**
243     * Gets the Aperture in F-Stops of the photo taken. Format is "f/6.0".
244     *
245     * @return Aperture string, or {@code null} if the information could not be retrieved
246     */
247    public String getAperture() {
248        Rational aperture = readRational(ExifSubIFDDirectory.class, ExifSubIFDDirectory.TAG_APERTURE);
249
250        if (aperture == null) {
251            aperture = readRational(XmpDirectory.class, XmpDirectory.TAG_APERTURE_VALUE);
252        }
253
254        if (aperture != null) {
255            double fstop = PhotographicConversions.apertureToFStop(aperture.doubleValue());
256            return String.format(Locale.ENGLISH, "f/%.1f", fstop);
257        }
258
259        return null;
260    }
261
262    /**
263     * Gets the Shutter Speed of the photo taken. Format is either "1/150 s" or "15.0 s".
264     *
265     * @return Shutter Speed string, or {@code null} if the information could not be
266     *         retrieved
267     */
268    public String getShutter() {
269        Rational shutter = readRational(ExifSubIFDDirectory.class, ExifSubIFDDirectory.TAG_SHUTTER_SPEED);
270
271        if (shutter == null) {
272            shutter = readRational(XmpDirectory.class, XmpDirectory.TAG_SHUTTER_SPEED);
273        }
274
275        if (shutter != null) {
276            double speed = PhotographicConversions.shutterSpeedToExposureTime(shutter.doubleValue());
277            if (speed <= .25d) {
278                return String.format(Locale.ENGLISH, "1/%.0f s", 1 / speed);
279            } else {
280                return String.format(Locale.ENGLISH, "%.1f s", speed);
281            }
282        }
283
284        return null;
285    }
286
287    /**
288     * Gets the ISO value of the photo taken. Format: "100".
289     *
290     * @return ISO string, or {@code null} if the information could not be retrieved
291     */
292    public String getIso() {
293        Integer iso = readInteger(ExifSubIFDDirectory.class, ExifSubIFDDirectory.TAG_ISO_EQUIVALENT);
294        if (iso != null) {
295            return String.format(Locale.ENGLISH, "%d", iso);
296        }
297
298        return null;
299
300    }
301
302    /**
303     * Gets the Exposure Bias of the photo taken. Format: "+0.7 EV" (zero is "+0.0 EV").
304     *
305     * @return Exposure Bias string, or {@code null} if the information could not be
306     *         retrieved
307     */
308    public String getExposureBias() {
309        Rational bias = readRational(ExifSubIFDDirectory.class, ExifSubIFDDirectory.TAG_EXPOSURE_BIAS);
310        if (bias != null) {
311            return String.format(Locale.ENGLISH, "%+.1f EV", bias.doubleValue());
312        }
313
314        return null;
315    }
316
317    /**
318     * Gets the Focal Length of the photo taken. Format: "123.0 mm". If there is also a 35
319     * mm film equivalent focal length, the format is "123.0 mm (= 196.8 mm)".
320     *
321     * @return Focal Length string, or {@code null} if the information could not be
322     *         retrieved
323     */
324    public String getFocalLength() {
325        Rational focal = readRational(ExifSubIFDDirectory.class, ExifSubIFDDirectory.TAG_FOCAL_LENGTH);
326
327        if (focal == null) {
328            focal = readRational(XmpDirectory.class, XmpDirectory.TAG_FOCAL_LENGTH);
329        }
330
331        if (focal != null) {
332            String result = String.format(Locale.ENGLISH, "%.0f mm", focal.doubleValue());
333
334            Integer equiv = readInteger(ExifSubIFDDirectory.class, ExifSubIFDDirectory.TAG_35MM_FILM_EQUIV_FOCAL_LENGTH);
335            if (equiv != null && focal.intValue() != equiv.intValue()) {
336                result += String.format(Locale.ENGLISH, " (= %d mm)", equiv.intValue());
337            }
338
339            return result;
340        }
341
342        return null;
343    }
344
345    /**
346     * Reads the Flash Mode that was set on the camera for this photo. Flash Mode may
347     * consist of several information separated by comma (',').
348     *
349     * @return Flash Mode string, or {@code null} if the information could not be
350     *         retrieved
351     */
352    public String getFlash() {
353        Integer code;
354
355        code = readInteger(ExifSubIFDDirectory.class, ExifSubIFDDirectory.TAG_FLASH);
356        if (code != null) {
357            if (code == 0) return "no flash";
358
359            StringBuilder sb = new StringBuilder();
360            switch (code & 0x18) {
361                case 0x08: sb.append("on"); break;
362                case 0x10: sb.append("off"); break;
363                case 0x18: sb.append("auto"); break;
364            }
365
366            if ((code & 0x01) != 0) {
367                sb.append(",fired");
368            }
369
370            if ((code & 0x06) == 0x06) {
371                sb.append(",return detected");
372            }
373
374            // Too much information for a mere gallery
375            // if ((code & 0x06) == 0x04) {
376            // sb.append(",return not detected");
377            // }
378
379            // Too much information for a mere gallery
380            // if ((code & 0x20) != 0) {
381            // sb.append(",no flash function");
382            // }
383
384            if ((code & 0x40) != 0) {
385                sb.append(",red eye reduction");
386            }
387
388            if (sb.charAt(0) == ',') {
389                sb.deleteCharAt(0);
390            }
391
392            return sb.toString();
393        }
394
395        return null;
396    }
397
398    /**
399     * Reads the White Balance Mode that was set on the camera for this photo.
400     *
401     * @return White Balance Mode string, or {@code null} if the information could not be
402     *         retrieved
403     */
404    public String getWhiteBalance() {
405        Integer code;
406
407        code = readInteger(CanonMakernoteDirectory.class, CanonMakernoteDirectory.FocalLength.TAG_WHITE_BALANCE);
408        if (code != null) {
409            switch (code) {
410                case 0: return "auto";
411                case 1: return "daylight";
412                case 2: return "cloudy";
413                case 3: return "tungsten";
414                case 4: return "fluorescent";
415                case 5: return "flash";
416                case 6: return "manual";
417            }
418        }
419
420        code = readInteger(CasioType2MakernoteDirectory.class, CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_WHITE_BALANCE_1);
421        if (code != null) {
422            switch (code) {
423                case 0: return "auto";
424                case 1: return "daylight";
425                case 2: return "cloudy";
426                case 3: return "tungsten";
427                case 4: return "fluorescent";
428                case 5: return "manual";
429            }
430        }
431
432        code = readInteger(PentaxMakernoteDirectory.class, PentaxMakernoteDirectory.TAG_PENTAX_WHITE_BALANCE);
433        if (code != null) {
434            switch (code) {
435                case 0: return "auto";
436                case 1: return "daylight";
437                case 2: return "cloudy";
438                case 3: return "tungsten";
439                case 4: return "fluorescent";
440                case 5: return "manual";
441            }
442        }
443
444        // Other makes are undocumented and thus not evaluated
445
446        code = readInteger(ExifSubIFDDirectory.class, ExifSubIFDDirectory.TAG_WHITE_BALANCE);
447        if (code != null) {
448            switch (code) {
449                case 1: return "daylight";
450                case 2: return "fluorescent";
451                case 3: return "tungsten";
452                case 10: return "flash";
453            }
454        }
455
456        code = readInteger(ExifSubIFDDirectory.class, ExifSubIFDDirectory.TAG_WHITE_BALANCE_MODE);
457        if (code != null) {
458            switch (code) {
459                case 0: return "auto";
460                case 1: return "manual";
461            }
462        }
463
464        return null;
465    }
466
467    /**
468     * Reads the Metering Mode that was set on the camera for this photo.
469     *
470     * @return Metering Mode string, or {@code null} if the information could not be
471     *         retrieved
472     */
473    public String getMeteringMode() {
474        Integer code;
475
476        code = readInteger(ExifSubIFDDirectory.class, ExifSubIFDDirectory.TAG_METERING_MODE);
477        if (code != null) {
478            switch (code) {
479                case 1: return "average";
480                case 2: return "center weighted average";
481                case 3: return "spot";
482                case 4: return "multi spot";
483                case 5: return "multi segment";
484                case 6: return "partial";
485            }
486        }
487
488        return null;
489    }
490
491    /**
492     * Reads the Focus Mode that was set on the camera for this photo.
493     *
494     * @return Focus Mode string, or {@code null} if the information could not be
495     *         retrieved
496     */
497    public String getFocusMode() {
498        Integer code;
499
500        code = readInteger(CanonMakernoteDirectory.class, CanonMakernoteDirectory.CameraSettings.TAG_FOCUS_MODE_1);
501        if (code != null) {
502            switch (code) {
503                case  0: return "one shot";
504                case  1: return "ai servo";
505                case  2: return "ai focus";
506                case  3: return "manual";
507                case  4: return "single";
508                case  5: return "continuous";
509                case  6: return "manual";
510                case 16: return "pan";
511            }
512        }
513
514        code = readInteger(CasioType2MakernoteDirectory.class, CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_FOCUS_MODE_2);
515        if (code != null) {
516            switch (code) {
517                case 0: return "manual";
518                case 1: return "focus lock";
519                case 2: return "macro";
520                case 3: return "single-area";
521                case 5: return "infinity";
522                case 6: return "multi-area";
523                case 8: return "super macro";
524            }
525        }
526
527        return null;
528    }
529
530    /**
531     * Reads the Program that was set on the camera for this photo.
532     *
533     * @return Program string, or {@code null} if the information could not be retrieved
534     */
535    public String getProgram() {
536        Integer code;
537
538        code = readInteger(CanonMakernoteDirectory.class, CanonMakernoteDirectory.CameraSettings.TAG_EXPOSURE_MODE);
539        if (code != null) {
540            switch (code) {
541                case 1: return "program";
542                case 2: return "shutter speed priority";
543                case 3: return "aperture priority";
544                case 4: return "manual";
545                case 5: return "depth-of-field";
546                case 6: return "m-dep";
547                case 7: return "bulb";
548            }
549        }
550
551        code = readInteger(CanonMakernoteDirectory.class, CanonMakernoteDirectory.CameraSettings.TAG_EASY_SHOOTING_MODE);
552        if (code != null) {
553            switch (code) {
554                case   0: return "auto";
555                case   1: return "easy";
556                case   2: return "landscape";
557                case   3: return "fast shutter";
558                case   4: return "slow shutter";
559                case   5: return "night";
560                case   6: return "gray scale";
561                case   7: return "sepia";
562                case   8: return "portrait";
563                case   9: return "sports";
564                case  10: return "macro";
565                case  11: return "black and white";
566                case  13: return "vivid";
567                case  14: return "neutral";
568                case  15: return "flash off";
569                case  16: return "long shutter";
570                case  17: return "super macro";
571                case  18: return "foliage";
572                case  19: return "indoor";
573                case  20: return "fireworks";
574                case  21: return "beach";
575                case  22: return "underwater";
576                case  23: return "snow";
577                case  24: return "kids and pets";
578                case  25: return "night snapshot";
579                case  26: return "digital macro";
580                case  27: return "my colors";
581                case  28: return "still image";
582                case  30: return "color accent";
583                case  31: return "color swap";
584                case  32: return "aquarium";
585                case  33: return "iso 3200";
586                case  38: return "creative auto";
587                case 261: return "sunset";
588            }
589        }
590
591        code = readInteger(CasioType2MakernoteDirectory.class, CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_RECORD_MODE);
592        if (code != null) {
593            switch (code) {
594                case  2: return "program";
595                case  3: return "shutter priority";
596                case  4: return "aperture priority";
597                case  5: return "manual";
598                case  6: return "best shot";
599                case 17: // -v-
600                case 19: return "movie";
601            }
602        }
603
604        return null;
605    }
606
607    /**
608     * Fetches a String from a directory.
609     *
610     * @param directory
611     *            Directory to read from
612     * @param tag
613     *            Tag to be read
614     * @return String that was read, or {@code null} if there was no such information
615     */
616    protected <T extends Directory> String readString(Class<T> directory, int tag) {
617        if (metadata.containsDirectory(directory)) {
618            T dir = metadata.getDirectory(directory);
619            if (dir.containsTag(tag)) {
620                return dir.getString(tag);
621            }
622        }
623
624        return null;
625    }
626
627    /**
628     * Fetches a Rational from a directory.
629     *
630     * @param directory
631     *            Directory to read from
632     * @param tag
633     *            Tag to be read
634     * @return Rational that was read, or {@code null} if there was no such information
635     */
636    protected <T extends Directory> Rational readRational(Class<T> directory, int tag) {
637        if (metadata.containsDirectory(directory)) {
638            T dir = metadata.getDirectory(directory);
639            if (dir.containsTag(tag)) {
640                return dir.getRational(tag);
641            }
642        }
643
644        return null;
645    }
646
647    /**
648     * Fetches an Integer from a directory.
649     *
650     * @param directory
651     *            Directory to read from
652     * @param tag
653     *            Tag to be read
654     * @return Integer that was read, or {@code null} if there was no such information
655     */
656    protected <T extends Directory> Integer readInteger(Class<T> directory, int tag) {
657        try {
658            if (metadata.containsDirectory(directory)) {
659                T dir = metadata.getDirectory(directory);
660                if (dir.containsTag(tag)) {
661                    return dir.getInt(tag);
662                }
663            }
664        } catch (MetadataException ex) {
665            log.warn("Exception while reading integer", ex);
666        }
667
668        return null;
669    }
670
671    /**
672     * Fetches a {@link Date} from a directory.
673     *
674     * @param directory
675     *            Directory to read from
676     * @param tag
677     *            Tag to be read
678     * @param tz
679     *            TimeZone the camera is configured to
680     * @return Date that was read, or {@code null} if there was no such information
681     */
682    protected <T extends Directory> Date readDate(Class<T> directory, int tag, TimeZone tz) {
683        if (metadata.containsDirectory(directory)) {
684            T dir = metadata.getDirectory(directory);
685            if (dir.containsTag(tag)) {
686                return dir.getDate(tag, tz);
687            }
688        }
689
690        return null;
691    }
692
693    /**
694     * Converts an angle from a directory. The implementation handles one to three (and
695     * even more) rational array entries.
696     *
697     * @param directory
698     *            Directory to read from
699     * @param tag
700     *            Tag to be read
701     * @return BigDecimal containing the angle, probably rounded
702     */
703    protected <T extends Directory> BigDecimal readAngle(Class<T> directory, int tag) {
704        if (metadata.containsDirectory(directory)) {
705            T dir = metadata.getDirectory(directory);
706            if (dir.containsTag(tag)) {
707                Rational[] data = dir.getRationalArray(tag);
708
709                double result = 0d;
710                for (int ix = data.length - 1; ix >= 0; ix--) {
711                    result = (result / 60d) + data[ix].doubleValue();
712                }
713
714                return new BigDecimal(result).setScale(6, RoundingMode.HALF_DOWN);
715            }
716        }
717
718        return null;
719    }
720
721}