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