001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.layer.geoimage;
003
004import java.awt.Image;
005import java.io.File;
006import java.io.IOException;
007import java.text.ParseException;
008import java.util.Calendar;
009import java.util.Collections;
010import java.util.Date;
011import java.util.GregorianCalendar;
012import java.util.TimeZone;
013
014import org.openstreetmap.josm.Main;
015import org.openstreetmap.josm.data.SystemOfMeasurement;
016import org.openstreetmap.josm.data.coor.CachedLatLon;
017import org.openstreetmap.josm.data.coor.LatLon;
018import org.openstreetmap.josm.tools.ExifReader;
019
020import com.drew.imaging.jpeg.JpegMetadataReader;
021import com.drew.lang.CompoundException;
022import com.drew.metadata.Directory;
023import com.drew.metadata.Metadata;
024import com.drew.metadata.MetadataException;
025import com.drew.metadata.exif.ExifIFD0Directory;
026import com.drew.metadata.exif.GpsDirectory;
027
028/**
029 * Stores info about each image
030 */
031public final class ImageEntry implements Comparable<ImageEntry>, Cloneable {
032    private File file;
033    private Integer exifOrientation;
034    private LatLon exifCoor;
035    private Double exifImgDir;
036    private Date exifTime;
037    /**
038     * Flag isNewGpsData indicates that the GPS data of the image is new or has changed.
039     * GPS data includes the position, speed, elevation, time (e.g. as extracted from the GPS track).
040     * The flag can used to decide for which image file the EXIF GPS data is (re-)written.
041     */
042    private boolean isNewGpsData;
043    /** Temporary source of GPS time if not correlated with GPX track. */
044    private Date exifGpsTime;
045    private Image thumbnail;
046
047    /**
048     * The following values are computed from the correlation with the gpx track
049     * or extracted from the image EXIF data.
050     */
051    private CachedLatLon pos;
052    /** Speed in kilometer per hour */
053    private Double speed;
054    /** Elevation (altitude) in meters */
055    private Double elevation;
056    /** The time after correlation with a gpx track */
057    private Date gpsTime;
058
059    /**
060     * When the correlation dialog is open, we like to show the image position
061     * for the current time offset on the map in real time.
062     * On the other hand, when the user aborts this operation, the old values
063     * should be restored. We have a temporary copy, that overrides
064     * the normal values if it is not null. (This may be not the most elegant
065     * solution for this, but it works.)
066     */
067    ImageEntry tmp;
068
069    /**
070     * Constructs a new {@code ImageEntry}.
071     */
072    public ImageEntry() {}
073
074    /**
075     * Constructs a new {@code ImageEntry}.
076     * @param file Path to image file on disk
077     */
078    public ImageEntry(File file) {
079        setFile(file);
080    }
081
082    /**
083     * Returns the position value. The position value from the temporary copy
084     * is returned if that copy exists.
085     * @return the position value
086     */
087    public CachedLatLon getPos() {
088        if (tmp != null)
089            return tmp.pos;
090        return pos;
091    }
092
093    /**
094     * Returns the speed value. The speed value from the temporary copy is
095     * returned if that copy exists.
096     * @return the speed value
097     */
098    public Double getSpeed() {
099        if (tmp != null)
100            return tmp.speed;
101        return speed;
102    }
103
104    /**
105     * Returns the elevation value. The elevation value from the temporary
106     * copy is returned if that copy exists.
107     * @return the elevation value
108     */
109    public Double getElevation() {
110        if (tmp != null)
111            return tmp.elevation;
112        return elevation;
113    }
114
115    /**
116     * Returns the GPS time value. The GPS time value from the temporary copy
117     * is returned if that copy exists.
118     * @return the GPS time value
119     */
120    public Date getGpsTime() {
121        if (tmp != null)
122            return getDefensiveDate(tmp.gpsTime);
123        return getDefensiveDate(gpsTime);
124    }
125
126    /**
127     * Convenient way to determine if this entry has a GPS time, without the cost of building a defensive copy.
128     * @return {@code true} if this entry has a GPS time
129     * @since 6450
130     */
131    public boolean hasGpsTime() {
132        return (tmp != null && tmp.gpsTime != null) || gpsTime != null;
133    }
134
135    /**
136     * Returns associated file.
137     * @return associated file
138     */
139    public File getFile() {
140        return file;
141    }
142
143    /**
144     * Returns EXIF orientation
145     * @return EXIF orientation
146     */
147    public Integer getExifOrientation() {
148        return exifOrientation;
149    }
150
151    /**
152     * Returns EXIF time
153     * @return EXIF time
154     */
155    public Date getExifTime() {
156        return getDefensiveDate(exifTime);
157    }
158
159    /**
160     * Convenient way to determine if this entry has a EXIF time, without the cost of building a defensive copy.
161     * @return {@code true} if this entry has a EXIF time
162     * @since 6450
163     */
164    public boolean hasExifTime() {
165        return exifTime != null;
166    }
167
168    /**
169     * Returns the EXIF GPS time.
170     * @return the EXIF GPS time
171     * @since 6392
172     */
173    public Date getExifGpsTime() {
174        return getDefensiveDate(exifGpsTime);
175    }
176
177    /**
178     * Convenient way to determine if this entry has a EXIF GPS time, without the cost of building a defensive copy.
179     * @return {@code true} if this entry has a EXIF GPS time
180     * @since 6450
181     */
182    public boolean hasExifGpsTime() {
183        return exifGpsTime != null;
184    }
185
186    private static Date getDefensiveDate(Date date) {
187        if (date == null)
188            return null;
189        return new Date(date.getTime());
190    }
191
192    public LatLon getExifCoor() {
193        return exifCoor;
194    }
195
196    public Double getExifImgDir() {
197        if (tmp != null)
198            return tmp.exifImgDir;
199        return exifImgDir;
200    }
201
202    /**
203     * Determines whether a thumbnail is set
204     * @return {@code true} if a thumbnail is set
205     */
206    public boolean hasThumbnail() {
207        return thumbnail != null;
208    }
209
210    /**
211     * Returns the thumbnail.
212     * @return the thumbnail
213     */
214    public Image getThumbnail() {
215        return thumbnail;
216    }
217
218    /**
219     * Sets the thumbnail.
220     * @param thumbnail thumbnail
221     */
222    public void setThumbnail(Image thumbnail) {
223        this.thumbnail = thumbnail;
224    }
225
226    /**
227     * Loads the thumbnail if it was not loaded yet.
228     * @see ThumbsLoader
229     */
230    public void loadThumbnail() {
231        if (thumbnail == null) {
232            new ThumbsLoader(Collections.singleton(this)).run();
233        }
234    }
235
236    /**
237     * Sets the position.
238     * @param pos cached position
239     */
240    public void setPos(CachedLatLon pos) {
241        this.pos = pos;
242    }
243
244    /**
245     * Sets the position.
246     * @param pos position (will be cached)
247     */
248    public void setPos(LatLon pos) {
249        setPos(pos != null ? new CachedLatLon(pos) : null);
250    }
251
252    /**
253     * Sets the speed.
254     * @param speed speed
255     */
256    public void setSpeed(Double speed) {
257        this.speed = speed;
258    }
259
260    /**
261     * Sets the elevation.
262     * @param elevation elevation
263     */
264    public void setElevation(Double elevation) {
265        this.elevation = elevation;
266    }
267
268    /**
269     * Sets associated file.
270     * @param file associated file
271     */
272    public void setFile(File file) {
273        this.file = file;
274    }
275
276    /**
277     * Sets EXIF orientation.
278     * @param exifOrientation EXIF orientation
279     */
280    public void setExifOrientation(Integer exifOrientation) {
281        this.exifOrientation = exifOrientation;
282    }
283
284    /**
285     * Sets EXIF time.
286     * @param exifTime EXIF time
287     */
288    public void setExifTime(Date exifTime) {
289        this.exifTime = getDefensiveDate(exifTime);
290    }
291
292    /**
293     * Sets the EXIF GPS time.
294     * @param exifGpsTime the EXIF GPS time
295     * @since 6392
296     */
297    public void setExifGpsTime(Date exifGpsTime) {
298        this.exifGpsTime = getDefensiveDate(exifGpsTime);
299    }
300
301    public void setGpsTime(Date gpsTime) {
302        this.gpsTime = getDefensiveDate(gpsTime);
303    }
304
305    public void setExifCoor(LatLon exifCoor) {
306        this.exifCoor = exifCoor;
307    }
308
309    public void setExifImgDir(Double exifDir) {
310        this.exifImgDir = exifDir;
311    }
312
313    @Override
314    public ImageEntry clone() {
315        Object c;
316        try {
317            c = super.clone();
318        } catch (CloneNotSupportedException e) {
319            throw new RuntimeException(e);
320        }
321        return (ImageEntry) c;
322    }
323
324    @Override
325    public int compareTo(ImageEntry image) {
326        if (exifTime != null && image.exifTime != null)
327            return exifTime.compareTo(image.exifTime);
328        else if (exifTime == null && image.exifTime == null)
329            return 0;
330        else if (exifTime == null)
331            return -1;
332        else
333            return 1;
334    }
335
336    /**
337     * Make a fresh copy and save it in the temporary variable. Use
338     * {@link #applyTmp()} or {@link #discardTmp()} if the temporary variable
339     * is not needed anymore.
340     */
341    public void createTmp() {
342        tmp = clone();
343        tmp.tmp = null;
344    }
345
346    /**
347     * Get temporary variable that is used for real time parameter
348     * adjustments. The temporary variable is created if it does not exist
349     * yet. Use {@link #applyTmp()} or {@link #discardTmp()} if the temporary
350     * variable is not needed anymore.
351     * @return temporary variable
352     */
353    public ImageEntry getTmp() {
354        if (tmp == null) {
355            createTmp();
356        }
357        return tmp;
358    }
359
360    /**
361     * Copy the values from the temporary variable to the main instance. The
362     * temporary variable is deleted.
363     * @see #discardTmp()
364     */
365    public void applyTmp() {
366        if (tmp != null) {
367            pos = tmp.pos;
368            speed = tmp.speed;
369            elevation = tmp.elevation;
370            gpsTime = tmp.gpsTime;
371            exifImgDir = tmp.exifImgDir;
372            tmp = null;
373        }
374    }
375
376    /**
377     * Delete the temporary variable. Temporary modifications are lost.
378     * @see #applyTmp()
379     */
380    public void discardTmp() {
381        tmp = null;
382    }
383
384    /**
385     * If it has been tagged i.e. matched to a gpx track or retrieved lat/lon from exif
386     * @return {@code true} if it has been tagged
387     */
388    public boolean isTagged() {
389        return pos != null;
390    }
391
392    /**
393     * String representation. (only partial info)
394     */
395    @Override
396    public String toString() {
397        return file.getName()+": "+
398        "pos = "+pos+" | "+
399        "exifCoor = "+exifCoor+" | "+
400        (tmp == null ? " tmp==null" :
401            " [tmp] pos = "+tmp.pos);
402    }
403
404    /**
405     * Indicates that the image has new GPS data.
406     * That flag is set by new GPS data providers.  It is used e.g. by the photo_geotagging plugin
407     * to decide for which image file the EXIF GPS data needs to be (re-)written.
408     * @since 6392
409     */
410    public void flagNewGpsData() {
411        isNewGpsData = true;
412   }
413
414    /**
415     * Remove the flag that indicates new GPS data.
416     * The flag is cleared by a new GPS data consumer.
417     */
418    public void unflagNewGpsData() {
419        isNewGpsData = false;
420    }
421
422    /**
423     * Queries whether the GPS data changed.
424     * @return {@code true} if GPS data changed, {@code false} otherwise
425     * @since 6392
426     */
427    public boolean hasNewGpsData() {
428        return isNewGpsData;
429    }
430
431    /**
432     * Extract GPS metadata from image EXIF. Has no effect if the image file is not set
433     *
434     * If successful, fills in the LatLon, speed, elevation, image direction, and other attributes
435     * @since 9270
436     */
437    public void extractExif() {
438
439        Metadata metadata;
440        Directory dirExif;
441        GpsDirectory dirGps;
442
443        if (file == null) {
444            return;
445        }
446
447        try {
448            metadata = JpegMetadataReader.readMetadata(file);
449            dirExif = metadata.getFirstDirectoryOfType(ExifIFD0Directory.class);
450            dirGps = metadata.getFirstDirectoryOfType(GpsDirectory.class);
451        } catch (CompoundException | IOException p) {
452            Main.warn(p);
453            setExifCoor(null);
454            setPos(null);
455            return;
456        }
457
458        try {
459            if (dirExif != null) {
460                int orientation = dirExif.getInt(ExifIFD0Directory.TAG_ORIENTATION);
461                setExifOrientation(orientation);
462            }
463        } catch (MetadataException ex) {
464            if (Main.isDebugEnabled()) {
465                Main.debug(ex.getMessage());
466            }
467        }
468
469        // Changed to silently cope with no time info in exif. One case
470        // of person having time that couldn't be parsed, but valid GPS info
471        try {
472            setExifTime(ExifReader.readTime(file));
473        } catch (ParseException ex) {
474            setExifTime(null);
475        }
476
477        if (dirGps == null) {
478            setExifCoor(null);
479            setPos(null);
480            return;
481        }
482
483        try {
484            double speed = dirGps.getDouble(GpsDirectory.TAG_SPEED);
485            String speedRef = dirGps.getString(GpsDirectory.TAG_SPEED_REF);
486            if ("M".equalsIgnoreCase(speedRef)) {
487                // miles per hour
488                speed *= SystemOfMeasurement.IMPERIAL.bValue / 1000;
489            } else if ("N".equalsIgnoreCase(speedRef)) {
490                // knots == nautical miles per hour
491                speed *= SystemOfMeasurement.NAUTICAL_MILE.bValue / 1000;
492            }
493            // default is K (km/h)
494            setSpeed(speed);
495        } catch (Exception ex) {
496            if (Main.isDebugEnabled()) {
497                Main.debug(ex.getMessage());
498            }
499        }
500
501        try {
502            double ele = dirGps.getDouble(GpsDirectory.TAG_ALTITUDE);
503            int d = dirGps.getInt(GpsDirectory.TAG_ALTITUDE_REF);
504            if (d == 1) {
505                ele *= -1;
506            }
507            setElevation(ele);
508        } catch (MetadataException ex) {
509            if (Main.isDebugEnabled()) {
510                Main.debug(ex.getMessage());
511            }
512        }
513
514        try {
515            LatLon latlon = ExifReader.readLatLon(dirGps);
516            setExifCoor(latlon);
517            setPos(getExifCoor());
518
519        } catch (Exception ex) { // (other exceptions, e.g. #5271)
520            Main.error("Error reading EXIF from file: " + ex);
521            setExifCoor(null);
522            setPos(null);
523        }
524
525        try {
526            Double direction = ExifReader.readDirection(dirGps);
527            if (direction != null) {
528                setExifImgDir(direction);
529            }
530        } catch (Exception ex) { // (CompoundException and other exceptions, e.g. #5271)
531            if (Main.isDebugEnabled()) {
532                Main.debug(ex.getMessage());
533            }
534        }
535
536        // Time and date. We can have these cases:
537        // 1) GPS_TIME_STAMP not set -> date/time will be null
538        // 2) GPS_DATE_STAMP not set -> use EXIF date or set to default
539        // 3) GPS_TIME_STAMP and GPS_DATE_STAMP are set
540        int[] timeStampComps = dirGps.getIntArray(GpsDirectory.TAG_TIME_STAMP);
541        if (timeStampComps != null) {
542            int gpsHour = timeStampComps[0];
543            int gpsMin = timeStampComps[1];
544            int gpsSec = timeStampComps[2];
545            Calendar cal = new GregorianCalendar(TimeZone.getTimeZone("UTC"));
546
547            // We have the time. Next step is to check if the GPS date stamp is set.
548            // dirGps.getString() always succeeds, but the return value might be null.
549            String dateStampStr = dirGps.getString(GpsDirectory.TAG_DATE_STAMP);
550            if (dateStampStr != null && dateStampStr.matches("^\\d+:\\d+:\\d+$")) {
551                String[] dateStampComps = dateStampStr.split(":");
552                cal.set(Calendar.YEAR, Integer.parseInt(dateStampComps[0]));
553                cal.set(Calendar.MONTH, Integer.parseInt(dateStampComps[1]) - 1);
554                cal.set(Calendar.DAY_OF_MONTH, Integer.parseInt(dateStampComps[2]));
555            } else {
556                // No GPS date stamp in EXIF data. Copy it from EXIF time.
557                // Date is not set if EXIF time is not available.
558                if (hasExifTime()) {
559                    // Time not set yet, so we can copy everything, not just date.
560                    cal.setTime(getExifTime());
561                }
562            }
563
564            cal.set(Calendar.HOUR_OF_DAY, gpsHour);
565            cal.set(Calendar.MINUTE, gpsMin);
566            cal.set(Calendar.SECOND, gpsSec);
567
568            setExifGpsTime(cal.getTime());
569        }
570    }
571}