"""从图片 EXIF 提取拍摄时间与 GPS 地点标签。""" from __future__ import annotations from datetime import datetime from fractions import Fraction from pathlib import Path from typing import Optional from PIL import ExifTags, Image from app.core.logger import get_logger logger = get_logger(__name__) # EXIF 地点类标签前缀,重分析时保留不被 AI 覆盖 EXIF_TAG_PREFIX = "地点:" def _ratio_to_float(value) -> float: """EXIF 有理数 → float。""" if isinstance(value, tuple) and len(value) == 2: num, den = value return float(num) / float(den) if den else 0.0 if isinstance(value, Fraction): return float(value) return float(value) def _dms_to_decimal(dms: tuple, ref: str) -> Optional[float]: """度分秒 → 十进制度。""" try: deg, minutes, seconds = dms decimal = _ratio_to_float(deg) + _ratio_to_float(minutes) / 60 + _ratio_to_float(seconds) / 3600 if ref in ("S", "W"): decimal = -decimal return round(decimal, 6) except (TypeError, ValueError, ZeroDivisionError): return None def extract_image_metadata(path: Path) -> tuple[Optional[datetime], list[str]]: """读取 EXIF,返回 (拍摄时间, 地点标签列表)。""" captured: Optional[datetime] = None location_tags: list[str] = [] try: with Image.open(path) as img: exif = img.getexif() if not exif: return None, [] # 拍摄时间:优先 DateTimeOriginal for key in (36867, 36868, 306): # DateTimeOriginal / DateTimeDigitized / DateTime raw = exif.get(key) if raw: captured = _parse_exif_datetime(str(raw)) if captured: break # GPS → 地点标签 gps_ifd = exif.get_ifd(ExifTags.IFD.GPSInfo) if hasattr(exif, "get_ifd") else None if gps_ifd: lat = _dms_to_decimal( gps_ifd.get(2), gps_ifd.get(1, "N"), ) lon = _dms_to_decimal( gps_ifd.get(4), gps_ifd.get(3, "E"), ) if lat is not None and lon is not None: location_tags.append(f"{EXIF_TAG_PREFIX}{lat},{lon}") # 部分设备写入可读地名(XP Keywords / ImageDescription 等) for key, val in exif.items(): tag_name = ExifTags.TAGS.get(key, "") if tag_name in ("ImageDescription", "XPComment") and val: text = str(val).strip()[:64] if text and _looks_like_place(text): location_tags.append(f"{EXIF_TAG_PREFIX}{text}") except Exception as exc: # noqa: BLE001 logger.debug("读取 EXIF 失败 %s: %s", path.name, exc) return captured, location_tags def _parse_exif_datetime(raw: str) -> Optional[datetime]: """解析 EXIF 时间字符串。""" for fmt in ("%Y:%m:%d %H:%M:%S", "%Y-%m-%d %H:%M:%S"): try: return datetime.strptime(raw.strip(), fmt) except ValueError: continue return None def _looks_like_place(text: str) -> bool: """粗判字符串是否像地名(含中文或常见地址关键词)。""" keywords = ("市", "省", "区", "县", "镇", "村", "路", "街", "国", "GPS") return any(k in text for k in keywords) or any("\u4e00" <= c <= "\u9fff" for c in text) def is_exif_location_tag(name: str) -> bool: """是否为 EXIF 自动写入的地点标签。""" return name.startswith(EXIF_TAG_PREFIX)