Initial commit: snapAna 截图智能整理工具

包含 FastAPI 后端、React 前端、队列/OCR/标签/待办等完整功能。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wjl
2026-05-27 15:45:50 +08:00
commit 5c028d7952
76 changed files with 10467 additions and 0 deletions
+107
View File
@@ -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)