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