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}