Metadata: Ensure GPS lat/lng are within a valid range #2109
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
parent
6c71ad3c65
commit
3553f84872
|
@ -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()))
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
BIN
internal/meta/testdata/buggy_panorama.jpg
vendored
Normal file
BIN
internal/meta/testdata/buggy_panorama.jpg
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 11 KiB |
96
internal/meta/testdata/buggy_panorama.json
vendored
Normal file
96
internal/meta/testdata/buggy_panorama.json
vendored
Normal 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"
|
||||
}]
|
Loading…
Reference in a new issue