001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.data.gpx;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.Dimension;
007import java.awt.image.BufferedImage;
008import java.io.File;
009import java.io.IOException;
010import java.time.Instant;
011import java.util.Date;
012import java.util.List;
013import java.util.Locale;
014import java.util.Map;
015import java.util.Objects;
016import java.util.function.Consumer;
017import java.util.stream.Stream;
018
019import javax.imageio.IIOParam;
020
021import org.openstreetmap.josm.data.IQuadBucketType;
022import org.openstreetmap.josm.data.coor.CachedLatLon;
023import org.openstreetmap.josm.data.coor.LatLon;
024import org.openstreetmap.josm.data.imagery.street_level.Projections;
025import org.openstreetmap.josm.data.osm.BBox;
026import org.openstreetmap.josm.tools.ExifReader;
027import org.openstreetmap.josm.tools.JosmRuntimeException;
028import org.openstreetmap.josm.tools.Logging;
029
030import com.drew.imaging.jpeg.JpegMetadataReader;
031import com.drew.imaging.jpeg.JpegProcessingException;
032import com.drew.imaging.png.PngMetadataReader;
033import com.drew.imaging.png.PngProcessingException;
034import com.drew.imaging.tiff.TiffMetadataReader;
035import com.drew.imaging.tiff.TiffProcessingException;
036import com.drew.metadata.Directory;
037import com.drew.metadata.Metadata;
038import com.drew.metadata.MetadataException;
039import com.drew.metadata.exif.ExifIFD0Directory;
040import com.drew.metadata.exif.GpsDirectory;
041import com.drew.metadata.iptc.IptcDirectory;
042import com.drew.metadata.jpeg.JpegDirectory;
043import com.drew.metadata.xmp.XmpDirectory;
044
045/**
046 * Stores info about each image
047 * @since 14205 (extracted from gui.layer.geoimage.ImageEntry)
048 */
049public class GpxImageEntry implements Comparable<GpxImageEntry>, IQuadBucketType {
050    private File file;
051    private Integer exifOrientation;
052    private LatLon exifCoor;
053    private Double exifImgDir;
054    private Instant exifTime;
055    private Projections cameraProjection = Projections.UNKNOWN;
056    /**
057     * Flag isNewGpsData indicates that the GPS data of the image is new or has changed.
058     * GPS data includes the position, speed, elevation, time (e.g. as extracted from the GPS track).
059     * The flag can used to decide for which image file the EXIF GPS data is (re-)written.
060     */
061    private boolean isNewGpsData;
062    /** Temporary source of GPS time if not correlated with GPX track. */
063    private Instant exifGpsTime;
064
065    private String iptcCaption;
066    private String iptcHeadline;
067    private List<String> iptcKeywords;
068    private String iptcObjectName;
069
070    /**
071     * The following values are computed from the correlation with the gpx track
072     * or extracted from the image EXIF data.
073     */
074    private CachedLatLon pos;
075    /** Speed in kilometer per hour */
076    private Double speed;
077    /** Elevation (altitude) in meters */
078    private Double elevation;
079    /** The time after correlation with a gpx track */
080    private Instant gpsTime;
081
082    private int width;
083    private int height;
084
085    /**
086     * When the correlation dialog is open, we like to show the image position
087     * for the current time offset on the map in real time.
088     * On the other hand, when the user aborts this operation, the old values
089     * should be restored. We have a temporary copy, that overrides
090     * the normal values if it is not null. (This may be not the most elegant
091     * solution for this, but it works.)
092     */
093    private GpxImageEntry tmp;
094
095    /**
096     * Constructs a new {@code GpxImageEntry}.
097     */
098    public GpxImageEntry() {}
099
100    /**
101     * Constructs a new {@code GpxImageEntry} from an existing instance.
102     * @param other existing instance
103     * @since 14624
104     */
105    public GpxImageEntry(GpxImageEntry other) {
106        file = other.file;
107        exifOrientation = other.exifOrientation;
108        exifCoor = other.exifCoor;
109        exifImgDir = other.exifImgDir;
110        exifTime = other.exifTime;
111        isNewGpsData = other.isNewGpsData;
112        exifGpsTime = other.exifGpsTime;
113        pos = other.pos;
114        speed = other.speed;
115        elevation = other.elevation;
116        gpsTime = other.gpsTime;
117        width = other.width;
118        height = other.height;
119        tmp = other.tmp;
120    }
121
122    /**
123     * Constructs a new {@code GpxImageEntry}.
124     * @param file Path to image file on disk
125     */
126    public GpxImageEntry(File file) {
127        setFile(file);
128    }
129
130    /**
131     * Returns width of the image this GpxImageEntry represents.
132     * @return width of the image this GpxImageEntry represents
133     * @since 13220
134     */
135    public int getWidth() {
136        return width;
137    }
138
139    /**
140     * Returns height of the image this GpxImageEntry represents.
141     * @return height of the image this GpxImageEntry represents
142     * @since 13220
143     */
144    public int getHeight() {
145        return height;
146    }
147
148    /**
149     * Returns the position value. The position value from the temporary copy
150     * is returned if that copy exists.
151     * @return the position value
152     */
153    public CachedLatLon getPos() {
154        if (tmp != null)
155            return tmp.pos;
156        return pos;
157    }
158
159    /**
160     * Returns the speed value. The speed value from the temporary copy is
161     * returned if that copy exists.
162     * @return the speed value
163     */
164    public Double getSpeed() {
165        if (tmp != null)
166            return tmp.speed;
167        return speed;
168    }
169
170    /**
171     * Returns the elevation value. The elevation value from the temporary
172     * copy is returned if that copy exists.
173     * @return the elevation value
174     */
175    public Double getElevation() {
176        if (tmp != null)
177            return tmp.elevation;
178        return elevation;
179    }
180
181    /**
182     * Returns the GPS time value. The GPS time value from the temporary copy
183     * is returned if that copy exists.
184     * @return the GPS time value
185     * @deprecated Use {@link #getGpsInstant}
186     */
187    @Deprecated
188    public Date getGpsTime() {
189        if (tmp != null)
190            return getDefensiveDate(tmp.gpsTime);
191        return getDefensiveDate(gpsTime);
192    }
193
194    /**
195     * Returns the GPS time value. The GPS time value from the temporary copy
196     * is returned if that copy exists.
197     * @return the GPS time value
198     */
199    public Instant getGpsInstant() {
200        return tmp != null ? tmp.gpsTime : gpsTime;
201    }
202
203    /**
204     * Convenient way to determine if this entry has a GPS time, without the cost of building a defensive copy.
205     * @return {@code true} if this entry has a GPS time
206     * @since 6450
207     */
208    public boolean hasGpsTime() {
209        return (tmp != null && tmp.gpsTime != null) || gpsTime != null;
210    }
211
212    /**
213     * Returns associated file.
214     * @return associated file
215     */
216    public File getFile() {
217        return file;
218    }
219
220    /**
221     * Returns a display name for this entry
222     * @return a display name for this entry
223     */
224    public String getDisplayName() {
225        return file == null ? "" : file.getName();
226    }
227
228    /**
229     * Returns EXIF orientation
230     * @return EXIF orientation
231     */
232    public Integer getExifOrientation() {
233        return exifOrientation != null ? exifOrientation : 1;
234    }
235
236    /**
237     * Returns EXIF time
238     * @return EXIF time
239     * @since 17715
240     */
241    public Instant getExifInstant() {
242        return exifTime;
243    }
244
245    /**
246     * Convenient way to determine if this entry has a EXIF time, without the cost of building a defensive copy.
247     * @return {@code true} if this entry has a EXIF time
248     * @since 6450
249     */
250    public boolean hasExifTime() {
251        return exifTime != null;
252    }
253
254    /**
255     * Returns the EXIF GPS time.
256     * @return the EXIF GPS time
257     * @since 6392
258     * @deprecated Use {@link #getExifGpsInstant}
259     */
260    @Deprecated
261    public Date getExifGpsTime() {
262        return getDefensiveDate(exifGpsTime);
263    }
264
265    /**
266     * Returns the EXIF GPS time.
267     * @return the EXIF GPS time
268     * @since 17715
269     */
270    public Instant getExifGpsInstant() {
271        return exifGpsTime;
272    }
273
274    /**
275     * Convenient way to determine if this entry has a EXIF GPS time, without the cost of building a defensive copy.
276     * @return {@code true} if this entry has a EXIF GPS time
277     * @since 6450
278     */
279    public boolean hasExifGpsTime() {
280        return exifGpsTime != null;
281    }
282
283    private static Date getDefensiveDate(Instant date) {
284        if (date == null)
285            return null;
286        return Date.from(date);
287    }
288
289    public LatLon getExifCoor() {
290        return exifCoor;
291    }
292
293    public Double getExifImgDir() {
294        if (tmp != null)
295            return tmp.exifImgDir;
296        return exifImgDir;
297    }
298
299    /**
300     * Sets the width of this GpxImageEntry.
301     * @param width set the width of this GpxImageEntry
302     * @since 13220
303     */
304    public void setWidth(int width) {
305        this.width = width;
306    }
307
308    /**
309     * Sets the height of this GpxImageEntry.
310     * @param height set the height of this GpxImageEntry
311     * @since 13220
312     */
313    public void setHeight(int height) {
314        this.height = height;
315    }
316
317    /**
318     * Sets the position.
319     * @param pos cached position
320     */
321    public void setPos(CachedLatLon pos) {
322        this.pos = pos;
323    }
324
325    /**
326     * Sets the position.
327     * @param pos position (will be cached)
328     */
329    public void setPos(LatLon pos) {
330        setPos(pos != null ? new CachedLatLon(pos) : null);
331    }
332
333    /**
334     * Sets the speed.
335     * @param speed speed
336     */
337    public void setSpeed(Double speed) {
338        this.speed = speed;
339    }
340
341    /**
342     * Sets the elevation.
343     * @param elevation elevation
344     */
345    public void setElevation(Double elevation) {
346        this.elevation = elevation;
347    }
348
349    /**
350     * Sets associated file.
351     * @param file associated file
352     */
353    public void setFile(File file) {
354        this.file = file;
355    }
356
357    /**
358     * Sets EXIF orientation.
359     * @param exifOrientation EXIF orientation
360     */
361    public void setExifOrientation(Integer exifOrientation) {
362        this.exifOrientation = exifOrientation;
363    }
364
365    /**
366     * Sets EXIF time.
367     * @param exifTime EXIF time
368     * @since 17715
369     */
370    public void setExifTime(Instant exifTime) {
371        this.exifTime = exifTime;
372    }
373
374    /**
375     * Sets the EXIF GPS time.
376     * @param exifGpsTime the EXIF GPS time
377     * @since 17715
378     */
379    public void setExifGpsTime(Instant exifGpsTime) {
380        this.exifGpsTime = exifGpsTime;
381    }
382
383    /**
384     * Sets the GPS time.
385     * @param gpsTime the GPS time
386     * @since 17715
387     */
388    public void setGpsTime(Instant gpsTime) {
389        this.gpsTime = gpsTime;
390    }
391
392    public void setExifCoor(LatLon exifCoor) {
393        this.exifCoor = exifCoor;
394    }
395
396    public void setExifImgDir(Double exifDir) {
397        this.exifImgDir = exifDir;
398    }
399
400    /**
401     * Sets the IPTC caption.
402     * @param iptcCaption the IPTC caption
403     * @since 15219
404     */
405    public void setIptcCaption(String iptcCaption) {
406        this.iptcCaption = iptcCaption;
407    }
408
409    /**
410     * Sets the IPTC headline.
411     * @param iptcHeadline the IPTC headline
412     * @since 15219
413     */
414    public void setIptcHeadline(String iptcHeadline) {
415        this.iptcHeadline = iptcHeadline;
416    }
417
418    /**
419     * Sets the IPTC keywords.
420     * @param iptcKeywords the IPTC keywords
421     * @since 15219
422     */
423    public void setIptcKeywords(List<String> iptcKeywords) {
424        this.iptcKeywords = iptcKeywords;
425    }
426
427    /**
428     * Sets the IPTC object name.
429     * @param iptcObjectName the IPTC object name
430     * @since 15219
431     */
432    public void setIptcObjectName(String iptcObjectName) {
433        this.iptcObjectName = iptcObjectName;
434    }
435
436    /**
437     * Returns the IPTC caption.
438     * @return the IPTC caption
439     * @since 15219
440     */
441    public String getIptcCaption() {
442        return iptcCaption;
443    }
444
445    /**
446     * Returns the IPTC headline.
447     * @return the IPTC headline
448     * @since 15219
449     */
450    public String getIptcHeadline() {
451        return iptcHeadline;
452    }
453
454    /**
455     * Returns the IPTC keywords.
456     * @return the IPTC keywords
457     * @since 15219
458     */
459    public List<String> getIptcKeywords() {
460        return iptcKeywords;
461    }
462
463    /**
464     * Returns the IPTC object name.
465     * @return the IPTC object name
466     * @since 15219
467     */
468    public String getIptcObjectName() {
469        return iptcObjectName;
470    }
471
472    @Override
473    public int compareTo(GpxImageEntry image) {
474        if (exifTime != null && image.exifTime != null)
475            return exifTime.compareTo(image.exifTime);
476        else if (exifTime == null && image.exifTime == null)
477            return 0;
478        else if (exifTime == null)
479            return -1;
480        else
481            return 1;
482    }
483
484    @Override
485    public int hashCode() {
486        return Objects.hash(height, width, isNewGpsData,
487            elevation, exifCoor, exifGpsTime, exifImgDir, exifOrientation, exifTime,
488            iptcCaption, iptcHeadline, iptcKeywords, iptcObjectName,
489            file, gpsTime, pos, speed, tmp, cameraProjection);
490    }
491
492    @Override
493    public boolean equals(Object obj) {
494        if (this == obj)
495            return true;
496        if (obj == null || getClass() != obj.getClass())
497            return false;
498        GpxImageEntry other = (GpxImageEntry) obj;
499        return height == other.height
500            && width == other.width
501            && isNewGpsData == other.isNewGpsData
502            && Objects.equals(elevation, other.elevation)
503            && Objects.equals(exifCoor, other.exifCoor)
504            && Objects.equals(exifGpsTime, other.exifGpsTime)
505            && Objects.equals(exifImgDir, other.exifImgDir)
506            && Objects.equals(exifOrientation, other.exifOrientation)
507            && Objects.equals(exifTime, other.exifTime)
508            && Objects.equals(iptcCaption, other.iptcCaption)
509            && Objects.equals(iptcHeadline, other.iptcHeadline)
510            && Objects.equals(iptcKeywords, other.iptcKeywords)
511            && Objects.equals(iptcObjectName, other.iptcObjectName)
512            && Objects.equals(file, other.file)
513            && Objects.equals(gpsTime, other.gpsTime)
514            && Objects.equals(pos, other.pos)
515            && Objects.equals(speed, other.speed)
516            && Objects.equals(tmp, other.tmp)
517            && cameraProjection == other.cameraProjection;
518    }
519
520    /**
521     * Make a fresh copy and save it in the temporary variable. Use
522     * {@link #applyTmp()} or {@link #discardTmp()} if the temporary variable
523     * is not needed anymore.
524     * @return the fresh copy.
525     */
526    public GpxImageEntry createTmp() {
527        tmp = new GpxImageEntry(this);
528        tmp.tmp = null;
529        return tmp;
530    }
531
532    /**
533     * Get temporary variable that is used for real time parameter
534     * adjustments. The temporary variable is created if it does not exist
535     * yet. Use {@link #applyTmp()} or {@link #discardTmp()} if the temporary
536     * variable is not needed anymore.
537     * @return temporary variable
538     */
539    public GpxImageEntry getTmp() {
540        if (tmp == null) {
541            createTmp();
542        }
543        return tmp;
544    }
545
546    /**
547     * Copy the values from the temporary variable to the main instance. The
548     * temporary variable is deleted.
549     * @see #discardTmp()
550     */
551    public void applyTmp() {
552        if (tmp != null) {
553            pos = tmp.pos;
554            speed = tmp.speed;
555            elevation = tmp.elevation;
556            gpsTime = tmp.gpsTime;
557            exifImgDir = tmp.exifImgDir;
558            isNewGpsData = isNewGpsData || tmp.isNewGpsData;
559            tmp = null;
560        }
561        tmpUpdated();
562    }
563
564    /**
565     * Delete the temporary variable. Temporary modifications are lost.
566     * @see #applyTmp()
567     */
568    public void discardTmp() {
569        tmp = null;
570        tmpUpdated();
571    }
572
573    /**
574     * If it has been tagged i.e. matched to a gpx track or retrieved lat/lon from exif
575     * @return {@code true} if it has been tagged
576     */
577    public boolean isTagged() {
578        return pos != null;
579    }
580
581    /**
582     * String representation. (only partial info)
583     */
584    @Override
585    public String toString() {
586        return file.getName()+": "+
587        "pos = "+pos+" | "+
588        "exifCoor = "+exifCoor+" | "+
589        (tmp == null ? " tmp==null" :
590            " [tmp] pos = "+tmp.pos);
591    }
592
593    /**
594     * Indicates that the image has new GPS data.
595     * That flag is set by new GPS data providers.  It is used e.g. by the photo_geotagging plugin
596     * to decide for which image file the EXIF GPS data needs to be (re-)written.
597     * @since 6392
598     */
599    public void flagNewGpsData() {
600        isNewGpsData = true;
601   }
602
603    /**
604     * Indicate that the temporary copy has been updated. Mostly used to prevent UI issues.
605     * By default, this is a no-op. Override when needed in subclasses.
606     * @since 17579
607     */
608    protected void tmpUpdated() {
609        // No-op by default
610    }
611
612    @Override
613    public BBox getBBox() {
614        // new BBox(LatLon) is null safe.
615        // Use `getPos` instead of `getExifCoor` since the image may be correlated against a GPX track
616        return new BBox(this.getPos());
617    }
618
619    /**
620     * Remove the flag that indicates new GPS data.
621     * The flag is cleared by a new GPS data consumer.
622     */
623    public void unflagNewGpsData() {
624        isNewGpsData = false;
625    }
626
627    /**
628     * Queries whether the GPS data changed. The flag value from the temporary
629     * copy is returned if that copy exists.
630     * @return {@code true} if GPS data changed, {@code false} otherwise
631     * @since 6392
632     */
633    public boolean hasNewGpsData() {
634        if (tmp != null)
635            return tmp.isNewGpsData;
636        return isNewGpsData;
637    }
638
639    /**
640     * Extract GPS metadata from image EXIF. Has no effect if the image file is not set
641     *
642     * If successful, fills in the LatLon, speed, elevation, image direction, and other attributes
643     * @since 9270
644     */
645    public void extractExif() {
646
647        Metadata metadata;
648
649        if (file == null) {
650            return;
651        }
652
653        String fn = file.getName();
654
655        try {
656            // try to parse metadata according to extension
657            String ext = fn.substring(fn.lastIndexOf('.') + 1).toLowerCase(Locale.US);
658            switch (ext) {
659            case "jpg":
660            case "jpeg":
661                metadata = JpegMetadataReader.readMetadata(file);
662                break;
663            case "tif":
664            case "tiff":
665                metadata = TiffMetadataReader.readMetadata(file);
666                break;
667            case "png":
668                metadata = PngMetadataReader.readMetadata(file);
669                break;
670            default:
671                throw new NoMetadataReaderWarning(ext);
672            }
673        } catch (JpegProcessingException | TiffProcessingException | PngProcessingException | IOException
674                | NoMetadataReaderWarning topException) {
675            //try other formats (e.g. JPEG file with .png extension)
676            try {
677                metadata = JpegMetadataReader.readMetadata(file);
678            } catch (JpegProcessingException | IOException ex1) {
679                try {
680                    metadata = TiffMetadataReader.readMetadata(file);
681                } catch (TiffProcessingException | IOException ex2) {
682                    try {
683                        metadata = PngMetadataReader.readMetadata(file);
684                    } catch (PngProcessingException | IOException ex3) {
685                        Logging.warn(topException);
686                        Logging.info(tr("Can''t parse metadata for file \"{0}\". Using last modified date as timestamp.", fn));
687                        setExifTime(Instant.ofEpochMilli(file.lastModified()));
688                        setExifCoor(null);
689                        setPos(null);
690                        return;
691                    }
692                }
693            }
694        }
695
696        IptcDirectory dirIptc = metadata.getFirstDirectoryOfType(IptcDirectory.class);
697        if (dirIptc != null) {
698            ifNotNull(ExifReader.readCaption(dirIptc), this::setIptcCaption);
699            ifNotNull(ExifReader.readHeadline(dirIptc), this::setIptcHeadline);
700            ifNotNull(ExifReader.readKeywords(dirIptc), this::setIptcKeywords);
701            ifNotNull(ExifReader.readObjectName(dirIptc), this::setIptcObjectName);
702        }
703
704        for (XmpDirectory xmpDirectory : metadata.getDirectoriesOfType(XmpDirectory.class)) {
705            Map<String, String> properties = xmpDirectory.getXmpProperties();
706            final String projectionType = "GPano:ProjectionType";
707            if (properties.containsKey(projectionType)) {
708                Stream.of(Projections.values()).filter(p -> p.name().equalsIgnoreCase(properties.get(projectionType)))
709                        .findFirst().ifPresent(projection -> this.cameraProjection = projection);
710                break;
711            }
712        }
713
714        // Changed to silently cope with no time info in exif. One case
715        // of person having time that couldn't be parsed, but valid GPS info
716        Instant time = null;
717        try {
718            time = ExifReader.readInstant(metadata);
719        } catch (JosmRuntimeException | IllegalArgumentException | IllegalStateException ex) {
720            Logging.warn(ex);
721        }
722
723        if (time == null) {
724            Logging.info(tr("No EXIF time in file \"{0}\". Using last modified date as timestamp.", fn));
725            time = Instant.ofEpochMilli(file.lastModified()); //use lastModified time if no EXIF time present
726        }
727        setExifTime(time);
728
729        final Directory dir = metadata.getFirstDirectoryOfType(JpegDirectory.class);
730        final Directory dirExif = metadata.getFirstDirectoryOfType(ExifIFD0Directory.class);
731        final GpsDirectory dirGps = metadata.getFirstDirectoryOfType(GpsDirectory.class);
732
733        try {
734            if (dirExif != null && dirExif.containsTag(ExifIFD0Directory.TAG_ORIENTATION)) {
735                setExifOrientation(dirExif.getInt(ExifIFD0Directory.TAG_ORIENTATION));
736            }
737        } catch (MetadataException ex) {
738            Logging.debug(ex);
739        }
740
741        try {
742            if (dir != null && dir.containsTag(JpegDirectory.TAG_IMAGE_WIDTH) && dir.containsTag(JpegDirectory.TAG_IMAGE_HEIGHT)) {
743                // there are cases where these do not match width and height stored in dirExif
744                setWidth(dir.getInt(JpegDirectory.TAG_IMAGE_WIDTH));
745                setHeight(dir.getInt(JpegDirectory.TAG_IMAGE_HEIGHT));
746            }
747        } catch (MetadataException ex) {
748            Logging.debug(ex);
749        }
750
751        if (dirGps == null || dirGps.getTagCount() <= 1) {
752            setExifCoor(null);
753            setPos(null);
754            return;
755        }
756
757        ifNotNull(ExifReader.readSpeed(dirGps), this::setSpeed);
758        ifNotNull(ExifReader.readElevation(dirGps), this::setElevation);
759
760        try {
761            setExifCoor(ExifReader.readLatLon(dirGps));
762            setPos(getExifCoor());
763        } catch (MetadataException | IndexOutOfBoundsException ex) { // (other exceptions, e.g. #5271)
764            Logging.error("Error reading EXIF from file: " + ex);
765            setExifCoor(null);
766            setPos(null);
767        }
768
769        try {
770            ifNotNull(ExifReader.readDirection(dirGps), this::setExifImgDir);
771        } catch (IndexOutOfBoundsException ex) { // (other exceptions, e.g. #5271)
772            Logging.debug(ex);
773        }
774
775        ifNotNull(dirGps.getGpsDate(), d -> setExifGpsTime(d.toInstant()));
776    }
777
778    /**
779     * Reads the image represented by this entry in the given target dimension.
780     * @param target the desired dimension used for {@linkplain IIOParam#setSourceSubsampling subsampling} or {@code null}
781     * @return the read image, or {@code null}
782     * @throws IOException if any I/O error occurs
783     * @since 18246
784     */
785    public BufferedImage read(Dimension target) throws IOException {
786        throw new UnsupportedOperationException("read not implemented for " + this.getClass().getSimpleName());
787    }
788
789    private static class NoMetadataReaderWarning extends Exception {
790        NoMetadataReaderWarning(String ext) {
791            super("No metadata reader for format *." + ext);
792        }
793    }
794
795    private static <T> void ifNotNull(T value, Consumer<T> setter) {
796        if (value != null) {
797            setter.accept(value);
798        }
799    }
800
801    /**
802     * Get the projection type for this entry
803     * @return The projection type
804     * @since 18246
805     */
806    public Projections getProjectionType() {
807        return this.cameraProjection;
808    }
809
810    /**
811     * Returns a {@link WayPoint} representation of this GPX image entry.
812     * @return a {@code WayPoint} representation of this GPX image entry (containing position, instant and elevation)
813     * @since 18065
814     */
815    public WayPoint asWayPoint() {
816        CachedLatLon position = getPos();
817        WayPoint wpt = null;
818        if (position != null) {
819            wpt = new WayPoint(position);
820            wpt.setInstant(exifTime);
821            Double ele = getElevation();
822            if (ele != null) {
823                wpt.put(GpxConstants.PT_ELE, ele.toString());
824            }
825        }
826        return wpt;
827    }
828}