Metadata: Ensure GPS lat/lng are within a valid range #2109

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2022-12-28 20:12:30 +01:00
parent 6c71ad3c65
commit 3553f84872
8 changed files with 205 additions and 36 deletions

View file

@ -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()))
}

View file

@ -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)
})
}

View file

@ -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
}

View file

@ -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)
})
}

View file

@ -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))
}
}

View file

@ -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)
})
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -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"
}]