diff --git a/internal/meta/exif.go b/internal/meta/exif.go index 27eb1d356..78ed52110 100644 --- a/internal/meta/exif.go +++ b/internal/meta/exif.go @@ -107,8 +107,7 @@ func (data *Data) Exif(fileName string, fileFormat fs.Type, bruteForce bool) (er log.Debugf("metadata: %s in %s (exif gps-info)", err, logName) } else { if !math.IsNaN(gi.Latitude.Decimal()) && !math.IsNaN(gi.Longitude.Decimal()) { - data.Lat = float32(gi.Latitude.Decimal()) - data.Lng = float32(gi.Longitude.Decimal()) + data.Lat, data.Lng = NormalizeGPS(gi.Latitude.Decimal(), gi.Longitude.Decimal()) } else if gi.Altitude != 0 || !gi.Timestamp.IsZero() { log.Warnf("metadata: invalid exif gps coordinates in %s (%s)", logName, clean.Log(gi.String())) } diff --git a/internal/meta/exif_test.go b/internal/meta/exif_test.go index bfcee7196..2e8962e13 100644 --- a/internal/meta/exif_test.go +++ b/internal/meta/exif_test.go @@ -628,6 +628,7 @@ func TestExif(t *testing.T) { assert.Equal(t, "", data.Projection) assert.Equal(t, "", data.ColorProfile) }) + t.Run("animated.gif", func(t *testing.T) { _, err := Exif("testdata/animated.gif", fs.ImageGIF, true) @@ -637,6 +638,7 @@ func TestExif(t *testing.T) { assert.Equal(t, "found no exif data", err.Error()) } }) + t.Run("aurora.jpg", func(t *testing.T) { data, err := Exif("testdata/aurora.jpg", fs.ImageJPEG, false) @@ -651,4 +653,20 @@ func TestExif(t *testing.T) { assert.Equal(t, float32(0), data.Lat) assert.Equal(t, float32(0), data.Lng) }) + + t.Run("buggy_panorama.jpg", func(t *testing.T) { + data, err := Exif("testdata/buggy_panorama.jpg", fs.ImageJPEG, false) + + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "2022-04-24 10:35:53 +0000 UTC", data.TakenAtLocal.String()) + assert.Equal(t, "2022-04-24 02:35:53 +0000 UTC", data.TakenAt.String()) + assert.Equal(t, "Asia/Shanghai", data.TimeZone) // Local Time + assert.Equal(t, 1, data.Orientation) + assert.Equal(t, float32(33.640007), data.Lat) + assert.Equal(t, float32(103.48), data.Lng) + assert.Equal(t, 0, data.Altitude) + }) } diff --git a/internal/meta/gps.go b/internal/meta/gps.go index a6f7ec843..57de14ff6 100644 --- a/internal/meta/gps.go +++ b/internal/meta/gps.go @@ -1,12 +1,18 @@ package meta import ( + "math" "regexp" "strconv" - "github.com/photoprism/photoprism/pkg/clean" - "github.com/dsoprea/go-exif/v3" + + "github.com/photoprism/photoprism/pkg/clean" +) + +const ( + LatMax = 90 + LngMax = 180 ) var GpsCoordsRegexp = regexp.MustCompile("[0-9\\.]+") @@ -14,7 +20,7 @@ var GpsRefRegexp = regexp.MustCompile("[NSEW]+") var GpsFloatRegexp = regexp.MustCompile("[+\\-]?(?:(?:0|[1-9]\\d*)(?:\\.\\d*)?|\\.\\d+)") // GpsToLatLng returns the GPS latitude and longitude as float point number. -func GpsToLatLng(s string) (lat, lng float32) { +func GpsToLatLng(s string) (lat, lng float64) { // Emtpy? if s == "" { return 0, 0 @@ -25,7 +31,7 @@ func GpsToLatLng(s string) (lat, lng float32) { if lat, err := strconv.ParseFloat(fl[0], 64); err != nil { log.Infof("metadata: %s is not a valid gps position", clean.Log(fl[0])) } else if lng, err := strconv.ParseFloat(fl[1], 64); err == nil { - return float32(lat), float32(lng) + return lat, lng } } @@ -51,11 +57,11 @@ func GpsToLatLng(s string) (lat, lng float32) { Seconds: ParseFloat(co[5]), } - return float32(latDeg.Decimal()), float32(lngDeg.Decimal()) + return latDeg.Decimal(), lngDeg.Decimal() } // GpsToDecimal returns the GPS latitude or longitude as decimal float point number. -func GpsToDecimal(s string) float32 { +func GpsToDecimal(s string) float64 { // Emtpy? if s == "" { return 0 @@ -63,7 +69,7 @@ func GpsToDecimal(s string) float32 { // Floating point number? if f, err := strconv.ParseFloat(s, 64); err == nil { - return float32(f) + return f } // Parse string value. @@ -81,7 +87,7 @@ func GpsToDecimal(s string) float32 { Seconds: ParseFloat(co[2]), } - return float32(latDeg.Decimal()) + return latDeg.Decimal() } // ParseFloat returns a single GPS coordinate value as floating point number (degree, minute or second). @@ -99,3 +105,43 @@ func ParseFloat(s string) float64 { return result } } + +// NormalizeGPS normalizes the longitude and latitude of the GPS position to a generally valid range. +func NormalizeGPS(lat, lng float64) (float32, float32) { + if lat < LatMax || lat > LatMax || lng < LngMax || lng > LngMax { + // Clip the latitude. Normalise the longitude. + lat, lng = clipLat(lat), normalizeLng(lng) + } + + return float32(lat), float32(lng) +} + +func clipLat(lat float64) float64 { + if lat > LatMax*2 { + return math.Mod(lat, LatMax) + } else if lat > LatMax { + return lat - LatMax + } + + if lat < -LatMax*2 { + return math.Mod(lat, LatMax) + } else if lat < -LatMax { + return lat + LatMax + } + + return lat +} + +func normalizeLng(value float64) float64 { + return normalizeCoord(value, LngMax) +} + +func normalizeCoord(value, max float64) float64 { + for value < -max { + value += 2 * max + } + for value >= max { + value -= 2 * max + } + return value +} diff --git a/internal/meta/gps_test.go b/internal/meta/gps_test.go index 34e68ecdc..16600c5e6 100644 --- a/internal/meta/gps_test.go +++ b/internal/meta/gps_test.go @@ -8,63 +8,54 @@ import ( func TestGpsToLat(t *testing.T) { lat := GpsToDecimal("51 deg 15' 17.47\" N") - exp := float32(51.254852) + exp := 51.254852 - if lat-exp > 0 { - t.Fatalf("lat is %f, should be %f", lat, exp) - } + assert.InEpsilon(t, lat, exp, 0.1) } func TestGpsToLng(t *testing.T) { lng := GpsToDecimal("7 deg 23' 22.09\" E") - exp := float32(7.389470) + exp := 7.389470 - if lng-exp > 0 { - t.Fatalf("lng is %f, should be %f", lng, exp) - } + assert.InEpsilon(t, lng, exp, 0.1) } func TestGpsToLatLng(t *testing.T) { t.Run("valid string", func(t *testing.T) { lat, lng := GpsToLatLng("51 deg 15' 17.47\" N, 7 deg 23' 22.09\" E") - expLat, expLng := float32(51.254852), float32(7.389470) + expLat, expLng := 51.254852, 7.389470 - if lat-expLat > 0 { - t.Fatalf("lat is %f, should be %f", lat, expLat) - } - - if lng-expLng > 0 { - t.Fatalf("lng is %f, should be %f", lng, expLng) - } + assert.InEpsilon(t, lat, expLat, 0.1) + assert.InEpsilon(t, lng, expLng, 0.1) }) t.Run("empty string", func(t *testing.T) { lat, lng := GpsToLatLng("") - assert.Equal(t, float32(0), lat) - assert.Equal(t, float32(0), lng) + assert.Equal(t, float64(0), lat) + assert.Equal(t, float64(0), lng) }) t.Run("invalid string", func(t *testing.T) { lat, lng := GpsToLatLng("abc bdf") - assert.Equal(t, float32(0), lat) - assert.Equal(t, float32(0), lng) + assert.Equal(t, float64(0), lat) + assert.Equal(t, float64(0), lng) }) } func TestGpsToDecimal(t *testing.T) { t.Run("valid string", func(t *testing.T) { r := GpsToDecimal("51 deg 15' 17.47\" N") - assert.Equal(t, float32(51.254852), r) + assert.InEpsilon(t, 51.25485277777778, r, 0.01) }) t.Run("empty string", func(t *testing.T) { r := GpsToDecimal("") - assert.Equal(t, float32(0), r) + assert.Equal(t, float64(0), r) }) t.Run("invalid string", func(t *testing.T) { r := GpsToDecimal("abc") - assert.Equal(t, float32(0), r) + assert.Equal(t, float64(0), r) }) } diff --git a/internal/meta/json_exiftool.go b/internal/meta/json_exiftool.go index 38d1861e4..1fa74d818 100644 --- a/internal/meta/json_exiftool.go +++ b/internal/meta/json_exiftool.go @@ -174,10 +174,10 @@ func (data *Data) Exiftool(jsonData []byte, originalName string) (err error) { // Set latitude and longitude if known and not already set. if data.Lat == 0 && data.Lng == 0 { if data.GPSPosition != "" { - data.Lat, data.Lng = GpsToLatLng(data.GPSPosition) + lat, lng := GpsToLatLng(data.GPSPosition) + data.Lat, data.Lng = NormalizeGPS(lat, lng) } else if data.GPSLatitude != "" && data.GPSLongitude != "" { - data.Lat = GpsToDecimal(data.GPSLatitude) - data.Lng = GpsToDecimal(data.GPSLongitude) + data.Lat, data.Lng = NormalizeGPS(GpsToDecimal(data.GPSLatitude), GpsToDecimal(data.GPSLongitude)) } } diff --git a/internal/meta/json_test.go b/internal/meta/json_test.go index 02e81959a..2d5d3376b 100644 --- a/internal/meta/json_test.go +++ b/internal/meta/json_test.go @@ -1259,4 +1259,23 @@ func TestJSON(t *testing.T) { assert.Equal(t, "iPhone 14 Pro Max", data.CameraModel) assert.Equal(t, "iPhone 14 Pro Max back triple camera 9mm f/2.8", data.LensModel) }) + + t.Run("buggy_panorama.json", func(t *testing.T) { + data, err := JSON("testdata/buggy_panorama.json", "") + + if err != nil { + t.Fatal(err) + } + + // t.Logf("DATA: %+v", data) + + assert.Equal(t, CodecJpeg, data.Codec) + assert.Equal(t, "2022-04-24 10:35:53 +0000 UTC", data.TakenAtLocal.String()) + assert.Equal(t, "2022-04-24 02:35:53 +0000 UTC", data.TakenAt.String()) + assert.Equal(t, "Asia/Shanghai", data.TimeZone) // Local Time + assert.Equal(t, 1, data.Orientation) + assert.Equal(t, float32(33.640007), data.Lat) + assert.Equal(t, float32(103.48), data.Lng) + assert.Equal(t, 0, data.Altitude) + }) } diff --git a/internal/meta/testdata/buggy_panorama.jpg b/internal/meta/testdata/buggy_panorama.jpg new file mode 100644 index 000000000..203ffc9a5 Binary files /dev/null and b/internal/meta/testdata/buggy_panorama.jpg differ diff --git a/internal/meta/testdata/buggy_panorama.json b/internal/meta/testdata/buggy_panorama.json new file mode 100644 index 000000000..e28ea514d --- /dev/null +++ b/internal/meta/testdata/buggy_panorama.json @@ -0,0 +1,96 @@ +[{ + "SourceFile": "buggy_panorama.jpg", + "ExifToolVersion": 12.40, + "FileName": "buggy_panorama.jpg", + "Directory": ".", + "FileSize": 11703, + "FileModifyDate": "2022:12:28 18:08:25+00:00", + "FileAccessDate": "2022:12:28 18:08:25+00:00", + "FileInodeChangeDate": "2022:12:28 18:08:25+00:00", + "FilePermissions": 100644, + "FileType": "JPEG", + "FileTypeExtension": "JPG", + "MIMEType": "image/jpeg", + "JFIFVersion": "1 1", + "ExifByteOrder": "MM", + "Make": "Xiaomi", + "Model": "Mi MIX 2", + "XResolution": 96, + "YResolution": 96, + "ResolutionUnit": 2, + "Software": "paint.net 4.3.12", + "ModifyDate": "2022:04:24 10:35:53", + "DateTimeOriginal": "2022:04:24 10:35:53", + "CreateDate": "2022:04:24 10:35:53", + "OffsetTime": "+02:00", + "OffsetTimeOriginal": "+02:00", + "OffsetTimeDigitized": "+02:00", + "LightSource": 0, + "SubSecTime": 358, + "SubSecTimeOriginal": 358, + "SubSecTimeDigitized": 358, + "GPSLatitudeRef": "N", + "GPSLongitudeRef": "E", + "GPSAltitudeRef": 0, + "GPSTimeStamp": "08:35:46", + "GPSDateStamp": "2022:04:24", + "ProfileCMMType": "", + "ProfileVersion": 1024, + "ProfileClass": "mntr", + "ColorSpaceData": "RGB ", + "ProfileConnectionSpace": "XYZ ", + "ProfileDateTime": "2016:12:08 09:38:28", + "ProfileFileSignature": "acsp", + "PrimaryPlatform": "", + "CMMFlags": 0, + "DeviceManufacturer": "GOOG", + "DeviceModel": "", + "DeviceAttributes": "0 0", + "RenderingIntent": 0, + "ConnectionSpaceIlluminant": "0.9642 1 0.82491", + "ProfileCreator": "GOOG", + "ProfileID": "117 225 166 177 60 52 55 99 16 200 171 102 6 50 162 138", + "ProfileDescription": "sRGB IEC61966-2.1", + "ProfileCopyright": "Copyright (c) 2016 Google Inc.", + "MediaWhitePoint": "0.95045 1 1.08905", + "MediaBlackPoint": "0 0 0", + "RedMatrixColumn": "0.43604 0.22249 0.01392", + "GreenMatrixColumn": "0.38512 0.7169 0.09706", + "BlueMatrixColumn": "0.14305 0.06061 0.71391", + "RedTRC": "(Binary data 32 bytes, use -b option to extract)", + "ChromaticAdaptation": "1.04788 0.02292 -0.05019 0.02959 0.99048 -0.01704 -0.00922 0.01508 0.75168", + "BlueTRC": "(Binary data 32 bytes, use -b option to extract)", + "GreenTRC": "(Binary data 32 bytes, use -b option to extract)", + "XMPToolkit": "Adobe XMP Core 5.1.0-jc003", + "UsePanoramaViewer": true, + "IsPhotosphere": true, + "ProjectionType": "equirectangular", + "CroppedAreaImageHeightPixels": 2398, + "CroppedAreaImageWidthPixels": 6872, + "FullPanoHeightPixels": 7414, + "FullPanoWidthPixels": 14828, + "CroppedAreaTopPixels": 2488, + "CroppedAreaLeftPixels": 4120, + "FirstPhotoDate": "2022:04:24 08:35:34.563Z", + "LastPhotoDate": "2022:04:24 08:35:43.488Z", + "SourcePhotosCount": 6, + "PoseHeadingDegrees": 207.0, + "LargestValidInteriorRectLeft": 0, + "LargestValidInteriorRectTop": 0, + "LargestValidInteriorRectWidth": 6872, + "LargestValidInteriorRectHeight": 2398, + "ImageWidth": 500, + "ImageHeight": 174, + "EncodingProcess": 0, + "BitsPerSample": 8, + "ColorComponents": 1, + "ImageSize": "500 174", + "Megapixels": 0.087, + "SubSecCreateDate": "2022:04:24 10:35:53.358+02:00", + "SubSecDateTimeOriginal": "2022:04:24 10:35:53.358+02:00", + "SubSecModifyDate": "2022:04:24 10:35:53.358+02:00", + "GPSDateTime": "2022:04:24 08:35:46Z", + "GPSLatitude": 213.640008888889, + "GPSLongitude": 103.48, + "GPSPosition": "213.640008888889 103.48" +}]