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        try {
315            return (ImageEntry) super.clone();
316        } catch (CloneNotSupportedException e) {
317            throw new IllegalStateException(e);
318        }
319    }
320
321    @Override
322    public int compareTo(ImageEntry image) {
323        if (exifTime != null && image.exifTime != null)
324            return exifTime.compareTo(image.exifTime);
325        else if (exifTime == null && image.exifTime == null)
326            return 0;
327        else if (exifTime == null)
328            return -1;
329        else
330            return 1;
331    }
332
333    /**
334     * Make a fresh copy and save it in the temporary variable. Use
335     * {@link #applyTmp()} or {@link #discardTmp()} if the temporary variable
336     * is not needed anymore.
337     */
338    public void createTmp() {
339        tmp = clone();
340        tmp.tmp = null;
341    }
342
343    /**
344     * Get temporary variable that is used for real time parameter
345     * adjustments. The temporary variable is created if it does not exist
346     * yet. Use {@link #applyTmp()} or {@link #discardTmp()} if the temporary
347     * variable is not needed anymore.
348     * @return temporary variable
349     */
350    public ImageEntry getTmp() {
351        if (tmp == null) {
352            createTmp();
353        }
354        return tmp;
355    }
356
357    /**
358     * Copy the values from the temporary variable to the main instance. The
359     * temporary variable is deleted.
360     * @see #discardTmp()
361     */
362    public void applyTmp() {
363        if (tmp != null) {
364            pos = tmp.pos;
365            speed = tmp.speed;
366            elevation = tmp.elevation;
367            gpsTime = tmp.gpsTime;
368            exifImgDir = tmp.exifImgDir;
369            tmp = null;
370        }
371    }
372
373    /**
374     * Delete the temporary variable. Temporary modifications are lost.
375     * @see #applyTmp()
376     */
377    public void discardTmp() {
378        tmp = null;
379    }
380
381    /**
382     * If it has been tagged i.e. matched to a gpx track or retrieved lat/lon from exif
383     * @return {@code true} if it has been tagged
384     */
385    public boolean isTagged() {
386        return pos != null;
387    }
388
389    /**
390     * String representation. (only partial info)
391     */
392    @Override
393    public String toString() {
394        return file.getName()+": "+
395        "pos = "+pos+" | "+
396        "exifCoor = "+exifCoor+" | "+
397        (tmp == null ? " tmp==null" :
398            " [tmp] pos = "+tmp.pos);
399    }
400
401    /**
402     * Indicates that the image has new GPS data.
403     * That flag is set by new GPS data providers.  It is used e.g. by the photo_geotagging plugin
404     * to decide for which image file the EXIF GPS data needs to be (re-)written.
405     * @since 6392
406     */
407    public void flagNewGpsData() {
408        isNewGpsData = true;
409   }
410
411    /**
412     * Remove the flag that indicates new GPS data.
413     * The flag is cleared by a new GPS data consumer.
414     */
415    public void unflagNewGpsData() {
416        isNewGpsData = false;
417    }
418
419    /**
420     * Queries whether the GPS data changed.
421     * @return {@code true} if GPS data changed, {@code false} otherwise
422     * @since 6392
423     */
424    public boolean hasNewGpsData() {
425        return isNewGpsData;
426    }
427
428    /**
429     * Extract GPS metadata from image EXIF. Has no effect if the image file is not set
430     *
431     * If successful, fills in the LatLon, speed, elevation, image direction, and other attributes
432     * @since 9270
433     */
434    public void extractExif() {
435
436        Metadata metadata;
437        Directory dirExif;
438        GpsDirectory dirGps;
439
440        if (file == null) {
441            return;
442        }
443
444        // Changed to silently cope with no time info in exif. One case
445        // of person having time that couldn't be parsed, but valid GPS info
446        try {
447            setExifTime(ExifReader.readTime(file));
448        } catch (RuntimeException ex) {
449            setExifTime(null);
450        }
451
452        try {
453            metadata = JpegMetadataReader.readMetadata(file);
454            dirExif = metadata.getFirstDirectoryOfType(ExifIFD0Directory.class);
455            dirGps = metadata.getFirstDirectoryOfType(GpsDirectory.class);
456        } catch (CompoundException | IOException p) {
457            Main.warn(p);
458            setExifCoor(null);
459            setPos(null);
460            return;
461        }
462
463        try {
464            if (dirExif != null) {
465                int orientation = dirExif.getInt(ExifIFD0Directory.TAG_ORIENTATION);
466                setExifOrientation(orientation);
467            }
468        } catch (MetadataException ex) {
469            if (Main.isDebugEnabled()) {
470                Main.debug(ex.getMessage());
471            }
472        }
473
474        if (dirGps == null) {
475            setExifCoor(null);
476            setPos(null);
477            return;
478        }
479
480        try {
481            double speed = dirGps.getDouble(GpsDirectory.TAG_SPEED);
482            String speedRef = dirGps.getString(GpsDirectory.TAG_SPEED_REF);
483            if ("M".equalsIgnoreCase(speedRef)) {
484                // miles per hour
485                speed *= SystemOfMeasurement.IMPERIAL.bValue / 1000;
486            } else if ("N".equalsIgnoreCase(speedRef)) {
487                // knots == nautical miles per hour
488                speed *= SystemOfMeasurement.NAUTICAL_MILE.bValue / 1000;
489            }
490            // default is K (km/h)
491            setSpeed(speed);
492        } catch (MetadataException ex) {
493            if (Main.isDebugEnabled()) {
494                Main.debug(ex.getMessage());
495            }
496        }
497
498        try {
499            double ele = dirGps.getDouble(GpsDirectory.TAG_ALTITUDE);
500            int d = dirGps.getInt(GpsDirectory.TAG_ALTITUDE_REF);
501            if (d == 1) {
502                ele *= -1;
503            }
504            setElevation(ele);
505        } catch (MetadataException ex) {
506            if (Main.isDebugEnabled()) {
507                Main.debug(ex.getMessage());
508            }
509        }
510
511        try {
512            LatLon latlon = ExifReader.readLatLon(dirGps);
513            setExifCoor(latlon);
514            setPos(getExifCoor());
515
516        } catch (MetadataException | IndexOutOfBoundsException ex) { // (other exceptions, e.g. #5271)
517            Main.error("Error reading EXIF from file: " + ex);
518            setExifCoor(null);
519            setPos(null);
520        }
521
522        try {
523            Double direction = ExifReader.readDirection(dirGps);
524            if (direction != null) {
525                setExifImgDir(direction);
526            }
527        } catch (IndexOutOfBoundsException ex) { // (other exceptions, e.g. #5271)
528            if (Main.isDebugEnabled()) {
529                Main.debug(ex.getMessage());
530            }
531        }
532
533        // Time and date. We can have these cases:
534        // 1) GPS_TIME_STAMP not set -> date/time will be null
535        // 2) GPS_DATE_STAMP not set -> use EXIF date or set to default
536        // 3) GPS_TIME_STAMP and GPS_DATE_STAMP are set
537        int[] timeStampComps = dirGps.getIntArray(GpsDirectory.TAG_TIME_STAMP);
538        if (timeStampComps != null) {
539            int gpsHour = timeStampComps[0];
540            int gpsMin = timeStampComps[1];
541            int gpsSec = timeStampComps[2];
542            Calendar cal = new GregorianCalendar(TimeZone.getTimeZone("UTC"));
543
544            // We have the time. Next step is to check if the GPS date stamp is set.
545            // dirGps.getString() always succeeds, but the return value might be null.
546            String dateStampStr = dirGps.getString(GpsDirectory.TAG_DATE_STAMP);
547            if (dateStampStr != null && dateStampStr.matches("^\\d+:\\d+:\\d+$")) {
548                String[] dateStampComps = dateStampStr.split(":");
549                cal.set(Calendar.YEAR, Integer.parseInt(dateStampComps[0]));
550                cal.set(Calendar.MONTH, Integer.parseInt(dateStampComps[1]) - 1);
551                cal.set(Calendar.DAY_OF_MONTH, Integer.parseInt(dateStampComps[2]));
552            } else {
553                // No GPS date stamp in EXIF data. Copy it from EXIF time.
554                // Date is not set if EXIF time is not available.
555                if (hasExifTime()) {
556                    // Time not set yet, so we can copy everything, not just date.
557                    cal.setTime(getExifTime());
558                }
559            }
560
561            cal.set(Calendar.HOUR_OF_DAY, gpsHour);
562            cal.set(Calendar.MINUTE, gpsMin);
563            cal.set(Calendar.SECOND, gpsSec);
564
565            setExifGpsTime(cal.getTime());
566        }
567    }
568}