5c028d7952
包含 FastAPI 后端、React 前端、队列/OCR/标签/待办等完整功能。 Co-authored-by: Cursor <cursoragent@cursor.com>
108 lines
3.6 KiB
Python
108 lines
3.6 KiB
Python
"""从图片 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)
|