Initial commit: snapAna 截图智能整理工具
包含 FastAPI 后端、React 前端、队列/OCR/标签/待办等完整功能。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
"""从图片 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)
|
||||
Reference in New Issue
Block a user