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            Main.warn(ex);
450            setExifTime(null);
451        }
452
453        try {
454            metadata = JpegMetadataReader.readMetadata(file);
455            dirExif = metadata.getFirstDirectoryOfType(ExifIFD0Directory.class);
456            dirGps = metadata.getFirstDirectoryOfType(GpsDirectory.class);
457        } catch (CompoundException | IOException p) {
458            Main.warn(p);
459            setExifCoor(null);
460            setPos(null);
461            return;
462        }
463
464        try {
465            if (dirExif != null) {
466                int orientation = dirExif.getInt(ExifIFD0Directory.TAG_ORIENTATION);
467                setExifOrientation(orientation);
468            }
469        } catch (MetadataException ex) {
470            Main.debug(ex);
471        }
472
473        if (dirGps == null) {
474            setExifCoor(null);
475            setPos(null);
476            return;
477        }
478
479        try {
480            double speed = dirGps.getDouble(GpsDirectory.TAG_SPEED);
481            String speedRef = dirGps.getString(GpsDirectory.TAG_SPEED_REF);
482            if ("M".equalsIgnoreCase(speedRef)) {
483                // miles per hour
484                speed *= SystemOfMeasurement.IMPERIAL.bValue / 1000;
485            } else if ("N".equalsIgnoreCase(speedRef)) {
486                // knots == nautical miles per hour
487                speed *= SystemOfMeasurement.NAUTICAL_MILE.bValue / 1000;
488            }
489            // default is K (km/h)
490            setSpeed(speed);
491        } catch (MetadataException ex) {
492            Main.debug(ex);
493        }
494
495        try {
496            double ele = dirGps.getDouble(GpsDirectory.TAG_ALTITUDE);
497            int d = dirGps.getInt(GpsDirectory.TAG_ALTITUDE_REF);
498            if (d == 1) {
499                ele *= -1;
500            }
501            setElevation(ele);
502        } catch (MetadataException ex) {
503            Main.debug(ex);
504        }
505
506        try {
507            LatLon latlon = ExifReader.readLatLon(dirGps);
508            setExifCoor(latlon);
509            setPos(getExifCoor());
510
511        } catch (MetadataException | IndexOutOfBoundsException ex) { // (other exceptions, e.g. #5271)
512            Main.error("Error reading EXIF from file: " + ex);
513            setExifCoor(null);
514            setPos(null);
515        }
516
517        try {
518            Double direction = ExifReader.readDirection(dirGps);
519            if (direction != null) {
520                setExifImgDir(direction);
521            }
522        } catch (IndexOutOfBoundsException ex) { // (other exceptions, e.g. #5271)
523            Main.debug(ex);
524        }
525
526        // Time and date. We can have these cases:
527        // 1) GPS_TIME_STAMP not set -> date/time will be null
528        // 2) GPS_DATE_STAMP not set -> use EXIF date or set to default
529        // 3) GPS_TIME_STAMP and GPS_DATE_STAMP are set
530        int[] timeStampComps = dirGps.getIntArray(GpsDirectory.TAG_TIME_STAMP);
531        if (timeStampComps != null) {
532            int gpsHour = timeStampComps[0];
533            int gpsMin = timeStampComps[1];
534            int gpsSec = timeStampComps[2];
535            Calendar cal = new GregorianCalendar(TimeZone.getTimeZone("UTC"));
536
537            // We have the time. Next step is to check if the GPS date stamp is set.
538            // dirGps.getString() always succeeds, but the return value might be null.
539            String dateStampStr = dirGps.getString(GpsDirectory.TAG_DATE_STAMP);
540            if (dateStampStr != null && dateStampStr.matches("^\\d+:\\d+:\\d+$")) {
541                String[] dateStampComps = dateStampStr.split(":");
542                cal.set(Calendar.YEAR, Integer.parseInt(dateStampComps[0]));
543                cal.set(Calendar.MONTH, Integer.parseInt(dateStampComps[1]) - 1);
544                cal.set(Calendar.DAY_OF_MONTH, Integer.parseInt(dateStampComps[2]));
545            } else {
546                // No GPS date stamp in EXIF data. Copy it from EXIF time.
547                // Date is not set if EXIF time is not available.
548                if (hasExifTime()) {
549                    // Time not set yet, so we can copy everything, not just date.
550                    cal.setTime(getExifTime());
551                }
552            }
553
554            cal.set(Calendar.HOUR_OF_DAY, gpsHour);
555            cal.set(Calendar.MINUTE, gpsMin);
556            cal.set(Calendar.SECOND, gpsSec);
557
558            setExifGpsTime(cal.getTime());
559        }
560    }
561}