feat: map view with GPS extraction fix
Adds a new Map sidebar entry that plots photos by their EXIF GPS coordinates on a clustered Leaflet map. While wiring this up, the metadata extractor was reading unprefixed GPS keys that never exist in `exiftool -G -j` output AND assumed coordinates were already floats — every photo silently lost its GPS. The new extract_gps helper handles Composite/EXIF group prefixes and parses DMS strings, and lat/lon are stored as first-class indexed columns so the map can query them without parsing exif_json on every request. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ Metadata extraction service using ExifTool
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional
|
||||
@@ -40,44 +41,132 @@ def parse_exif_datetime(date_str: str) -> Optional[datetime]:
|
||||
|
||||
return None
|
||||
|
||||
_DMS_RE = re.compile(
|
||||
r"""\s*
|
||||
(?P<deg>-?\d+(?:\.\d+)?)\s*(?:deg|°|d)?\s*
|
||||
(?:(?P<min>\d+(?:\.\d+)?)\s*[\'’m]?\s*)?
|
||||
(?:(?P<sec>\d+(?:\.\d+)?)\s*[\"”s]?\s*)?
|
||||
(?P<ref>[NSEW])?\s*$""",
|
||||
re.IGNORECASE | re.VERBOSE,
|
||||
)
|
||||
|
||||
|
||||
def _parse_coord(value, ref: str | None) -> float | None:
|
||||
"""Coerce a single GPS coordinate from any form ExifTool may emit.
|
||||
|
||||
ExifTool's ``-j`` JSON output applies print conversion by default, so
|
||||
coordinates can come back as:
|
||||
|
||||
* a number (``48.1278``) — happens for some sources / when ``-n`` is set
|
||||
* a plain DMS string (``"48 deg 7' 39.96\\""``) — bare ``EXIF:GPSLatitude``
|
||||
* a DMS-with-ref string (``"48 deg 7' 39.96\\" N"``) — ``Composite:GPSLatitude``
|
||||
|
||||
The optional ``ref`` argument lets the caller pass an explicit
|
||||
``GPSLatitudeRef`` / ``GPSLongitudeRef`` ('N'/'S'/'E'/'W') when the
|
||||
string itself doesn't carry one. Returns signed decimal degrees, or
|
||||
``None`` if the value is unparseable.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
# Numeric path — already decimal degrees, possibly already signed.
|
||||
if isinstance(value, (int, float)):
|
||||
out = float(value)
|
||||
else:
|
||||
m = _DMS_RE.match(str(value))
|
||||
if not m:
|
||||
return None
|
||||
deg = float(m.group('deg'))
|
||||
minutes = float(m.group('min') or 0)
|
||||
seconds = float(m.group('sec') or 0)
|
||||
out = abs(deg) + minutes / 60.0 + seconds / 3600.0
|
||||
if deg < 0:
|
||||
out = -out
|
||||
embedded_ref = m.group('ref')
|
||||
if embedded_ref:
|
||||
ref = embedded_ref
|
||||
if ref:
|
||||
r = ref[0].upper()
|
||||
if r in ('S', 'W'):
|
||||
out = -abs(out)
|
||||
elif r in ('N', 'E'):
|
||||
out = abs(out)
|
||||
return out
|
||||
|
||||
|
||||
def extract_gps(exif_data: Dict) -> tuple:
|
||||
"""Return (lat, lon) in signed decimal degrees, or (None, None).
|
||||
|
||||
With ``exiftool -G -j`` GPS values are keyed under their group.
|
||||
``Composite:GPSLatitude`` / ``Composite:GPSLongitude`` carry the
|
||||
hemisphere reference inline (``"48 deg 7' 39.96\\" N"``) while the bare
|
||||
``EXIF:GPSLatitude`` / ``EXIF:GPSLongitude`` need the separate
|
||||
``EXIF:GPSLatitudeRef`` / ``EXIF:GPSLongitudeRef`` to know the sign.
|
||||
|
||||
Pre-fix this function read the *unprefixed* keys ``GPSLatitude`` /
|
||||
``GPSLongitude`` (which never exist in ``-G`` output) AND assumed
|
||||
they were already floats — so it silently dropped every photo's GPS.
|
||||
"""
|
||||
lat = _parse_coord(exif_data.get('Composite:GPSLatitude'), None)
|
||||
lon = _parse_coord(exif_data.get('Composite:GPSLongitude'), None)
|
||||
if lat is None or lon is None:
|
||||
lat = _parse_coord(
|
||||
exif_data.get('EXIF:GPSLatitude'),
|
||||
exif_data.get('EXIF:GPSLatitudeRef'),
|
||||
)
|
||||
lon = _parse_coord(
|
||||
exif_data.get('EXIF:GPSLongitude'),
|
||||
exif_data.get('EXIF:GPSLongitudeRef'),
|
||||
)
|
||||
if lat is None or lon is None:
|
||||
return None, None
|
||||
if not (-90 <= lat <= 90 and -180 <= lon <= 180):
|
||||
return None, None
|
||||
# Some cameras emit (0, 0) when they have no GPS lock — treat as missing
|
||||
if lat == 0 and lon == 0:
|
||||
return None, None
|
||||
return lat, lon
|
||||
|
||||
|
||||
def extract_key_metadata(exif_data: Dict) -> Dict:
|
||||
"""Extract key metadata fields for FTS indexing"""
|
||||
key_fields = []
|
||||
|
||||
|
||||
# Camera information
|
||||
if 'Make' in exif_data:
|
||||
key_fields.append(exif_data['Make'])
|
||||
if 'Model' in exif_data:
|
||||
key_fields.append(exif_data['Model'])
|
||||
if 'LensModel' in exif_data:
|
||||
key_fields.append(exif_data['LensModel'])
|
||||
|
||||
if 'EXIF:Make' in exif_data:
|
||||
key_fields.append(exif_data['EXIF:Make'])
|
||||
if 'EXIF:Model' in exif_data:
|
||||
key_fields.append(exif_data['EXIF:Model'])
|
||||
if 'EXIF:LensModel' in exif_data:
|
||||
key_fields.append(exif_data['EXIF:LensModel'])
|
||||
|
||||
# Location information
|
||||
if 'GPSLatitude' in exif_data and 'GPSLongitude' in exif_data:
|
||||
key_fields.append(f"GPS: {exif_data['GPSLatitude']}, {exif_data['GPSLongitude']}")
|
||||
|
||||
lat, lon = extract_gps(exif_data)
|
||||
if lat is not None and lon is not None:
|
||||
key_fields.append(f"GPS: {lat}, {lon}")
|
||||
|
||||
# IPTC/XMP keywords
|
||||
if 'Keywords' in exif_data:
|
||||
if isinstance(exif_data['Keywords'], list):
|
||||
key_fields.extend(exif_data['Keywords'])
|
||||
keywords = exif_data.get('IPTC:Keywords') or exif_data.get('XMP:Subject')
|
||||
if keywords:
|
||||
if isinstance(keywords, list):
|
||||
key_fields.extend(keywords)
|
||||
else:
|
||||
key_fields.append(exif_data['Keywords'])
|
||||
|
||||
key_fields.append(keywords)
|
||||
|
||||
# Copyright and creator
|
||||
if 'Copyright' in exif_data:
|
||||
key_fields.append(exif_data['Copyright'])
|
||||
if 'Creator' in exif_data:
|
||||
key_fields.append(exif_data['Creator'])
|
||||
if 'Artist' in exif_data:
|
||||
key_fields.append(exif_data['Artist'])
|
||||
|
||||
if 'EXIF:Copyright' in exif_data:
|
||||
key_fields.append(exif_data['EXIF:Copyright'])
|
||||
if 'XMP:Creator' in exif_data:
|
||||
key_fields.append(exif_data['XMP:Creator'])
|
||||
if 'EXIF:Artist' in exif_data:
|
||||
key_fields.append(exif_data['EXIF:Artist'])
|
||||
|
||||
return {
|
||||
'exif_text': ' '.join(key_fields),
|
||||
'camera_make': exif_data.get('Make'),
|
||||
'camera_model': exif_data.get('Model'),
|
||||
'lens_model': exif_data.get('LensModel'),
|
||||
'gps_latitude': exif_data.get('GPSLatitude'),
|
||||
'gps_longitude': exif_data.get('GPSLongitude'),
|
||||
'exif_text': ' '.join(str(f) for f in key_fields),
|
||||
'camera_make': exif_data.get('EXIF:Make'),
|
||||
'camera_model': exif_data.get('EXIF:Model'),
|
||||
'lens_model': exif_data.get('EXIF:LensModel'),
|
||||
'gps_latitude': lat,
|
||||
'gps_longitude': lon,
|
||||
}
|
||||
|
||||
@shared_task(name='extract_metadata')
|
||||
@@ -155,7 +244,13 @@ async def _extract_metadata_async(photo_id: str):
|
||||
photo.width = exif_data.get('EXIF:ImageWidth') or exif_data.get('File:ImageWidth')
|
||||
if not photo.height:
|
||||
photo.height = exif_data.get('EXIF:ImageHeight') or exif_data.get('File:ImageHeight')
|
||||
|
||||
|
||||
# Extract GPS coordinates into first-class columns so the
|
||||
# Map view can query them without parsing exif_json.
|
||||
lat, lon = extract_gps(exif_data)
|
||||
photo.latitude = lat
|
||||
photo.longitude = lon
|
||||
|
||||
# Extract and store key metadata for search
|
||||
key_metadata = extract_key_metadata(exif_data)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user