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