项目文件夹

文件
wehub-resource-sync 1d6fd29cda
tests / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:36:27 +08:00

469 行
16 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
#!/usr/bin/env python3
"""
simp-skill · Photo Analyzer
分析照片的 EXIF 元数据,提取拍摄时间线和地点信息,
并检测可能的约会/见面记录(同地点+同时段的照片聚类)。
依赖:pip install Pillow
支持格式:jpg / jpeg / png / heic / heif
用法:
python3 photo_analyzer.py --dir crushes/xiaomei/memories/photos
python3 photo_analyzer.py --dir ./photos --target 小美 --output report.md
"""
import os
import argparse
from pathlib import Path
from datetime import datetime, timedelta
from collections import defaultdict
from typing import Optional
try:
from PIL import Image
from PIL.ExifTags import TAGS, GPSTAGS
PIL_AVAILABLE = True
except ImportError:
PIL_AVAILABLE = False
PHOTO_EXTS = {".jpg", ".jpeg", ".png", ".heic", ".heif"}
# ─────────────────────────────────────────────
# EXIF 提取
# ─────────────────────────────────────────────
def get_exif_data(filepath: str) -> dict:
"""提取照片的 EXIF 元数据"""
if not PIL_AVAILABLE:
return {}
try:
img = Image.open(filepath)
raw_exif = img._getexif()
if not raw_exif:
return {}
exif = {}
for tag_id, value in raw_exif.items():
tag = TAGS.get(tag_id, tag_id)
exif[tag] = value
return exif
except Exception:
return {}
def get_datetime(exif: dict) -> Optional[datetime]:
"""从 EXIF 提取拍摄时间"""
for field in ("DateTimeOriginal", "DateTime", "DateTimeDigitized"):
raw = exif.get(field)
if raw:
try:
return datetime.strptime(str(raw), "%Y:%m:%d %H:%M:%S")
except ValueError:
continue
return None
def _dms_to_decimal(dms, ref: str) -> float:
"""将度分秒坐标转为十进制"""
try:
d = float(dms[0])
m = float(dms[1])
s = float(dms[2])
decimal = d + m / 60 + s / 3600
if ref in ("S", "W"):
decimal = -decimal
return round(decimal, 6)
except Exception:
return 0.0
def get_gps(exif: dict) -> Optional[dict]:
"""从 EXIF 提取 GPS 坐标"""
gps_raw = exif.get("GPSInfo")
if not gps_raw:
return None
gps = {}
for key, val in gps_raw.items():
tag = GPSTAGS.get(key, key)
gps[tag] = val
lat_dms = gps.get("GPSLatitude")
lat_ref = gps.get("GPSLatitudeRef", "N")
lon_dms = gps.get("GPSLongitude")
lon_ref = gps.get("GPSLongitudeRef", "E")
if lat_dms and lon_dms:
return {
"lat": _dms_to_decimal(lat_dms, lat_ref),
"lon": _dms_to_decimal(lon_dms, lon_ref),
}
return None
def get_make_model(exif: dict) -> str:
"""提取相机型号(可判断是谁拍的)"""
make = str(exif.get("Make", "")).strip()
model = str(exif.get("Model", "")).strip()
if make and model:
return f"{make} {model}"
return model or make or ""
# ─────────────────────────────────────────────
# 扫描与分析
# ─────────────────────────────────────────────
def scan_photos(directory: str) -> list:
"""扫描目录下所有照片并提取元数据"""
base = Path(directory)
if not base.exists():
print(f"⚠️ 目录不存在:{directory}")
return []
photos = []
for path in sorted(base.rglob("*")):
if not path.is_file():
continue
if path.suffix.lower() not in PHOTO_EXTS:
continue
exif = get_exif_data(str(path))
dt = get_datetime(exif)
gps = get_gps(exif)
cam = get_make_model(exif)
photos.append({
"path": str(path),
"name": path.name,
"rel": str(path.relative_to(base)),
"datetime": dt,
"gps": gps,
"camera": cam,
"size_kb": round(path.stat().st_size / 1024, 1),
})
# 按时间排序(无时间的排后面)
photos.sort(key=lambda p: (p["datetime"] is None, p["datetime"] or datetime.min))
return photos
# ─────────────────────────────────────────────
# 约会检测(核心创新功能)
# ─────────────────────────────────────────────
def _gps_distance_km(a: dict, b: dict) -> float:
"""粗略计算两个 GPS 坐标之间的距离(km),使用等经纬度近似"""
import math
lat1, lon1 = math.radians(a["lat"]), math.radians(a["lon"])
lat2, lon2 = math.radians(b["lat"]), math.radians(b["lon"])
dlat = lat2 - lat1
dlon = lon2 - lon1
a_ = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2
return 6371 * 2 * math.asin(math.sqrt(a_))
def detect_meetups(photos: list, time_gap_hours: float = 4.0, location_radius_km: float = 2.0) -> list:
"""
检测可能的约会/见面记录:
- 在同一时间段(time_gap_hours 以内)
- 在同一地点附近(location_radius_km 以内)
的照片聚类 = 可能的一次见面
返回聚类列表,每个聚类代表一次可能的见面。
"""
timed = [p for p in photos if p["datetime"] is not None]
if len(timed) < 2:
return []
visited = set()
meetups = []
for i, photo in enumerate(timed):
if i in visited:
continue
cluster = [photo]
visited.add(i)
for j, other in enumerate(timed):
if j in visited or j == i:
continue
# 时间差检测
delta = abs((other["datetime"] - photo["datetime"]).total_seconds()) / 3600
if delta > time_gap_hours:
continue
# GPS 检测(如果双方都有 GPS)
if photo["gps"] and other["gps"]:
dist = _gps_distance_km(photo["gps"], other["gps"])
if dist > location_radius_km:
continue
cluster.append(other)
visited.add(j)
if len(cluster) >= 2:
cluster.sort(key=lambda p: p["datetime"])
start = cluster[0]["datetime"]
end = cluster[-1]["datetime"]
duration = (end - start).seconds // 60
# 有 GPS 的取第一张的坐标
gps_ref = next((p["gps"] for p in cluster if p["gps"]), None)
meetups.append({
"date": start.strftime("%Y-%m-%d"),
"start": start.strftime("%H:%M"),
"end": end.strftime("%H:%M"),
"duration_min": duration,
"photo_count": len(cluster),
"gps": gps_ref,
"photos": cluster,
})
meetups.sort(key=lambda m: m["date"])
return meetups
# ─────────────────────────────────────────────
# 报告生成
# ─────────────────────────────────────────────
def generate_report(directory: str, target_name: str, output_path: str = None) -> str:
"""生成完整的照片分析报告"""
if not PIL_AVAILABLE:
warn = (
"⚠️ 未安装 Pillow,无法读取 EXIF 元数据。\n"
"请运行:pip install Pillow\n\n"
"安装后重新运行本工具以获取完整分析。"
)
if output_path:
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
Path(output_path).write_text(warn, encoding="utf-8")
return warn
photos = scan_photos(directory)
now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
lines = [
f"# 📷 照片元数据分析报告",
f"",
f"> 心上人:**{target_name}** | 分析时间:{now_str}",
f"> 来源目录:`{directory}`",
f"",
f"---",
f"",
]
if not photos:
lines += [
f"⚠️ 未找到任何照片文件。",
f"",
f"请将照片(.jpg / .jpeg / .png / .heic)放入 `{directory}/` 后重新运行。",
]
report = "\n".join(lines)
if output_path:
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
Path(output_path).write_text(report, encoding="utf-8")
print(f"✅ 报告已保存到 {output_path}")
return report
# 统计
with_time = [p for p in photos if p["datetime"]]
with_gps = [p for p in photos if p["gps"]]
lines += [
f"## 📊 概览",
f"",
f"| 指标 | 数值 |",
f"|------|------|",
f"| 照片总数 | {len(photos)} 张 |",
f"| 包含拍摄时间 | {len(with_time)} 张 |",
f"| 包含 GPS 位置 | {len(with_gps)} 张 |",
]
if with_time:
first = with_time[0]["datetime"]
last = with_time[-1]["datetime"]
lines += [
f"| 最早照片 | {first.strftime('%Y-%m-%d')} |",
f"| 最新照片 | {last.strftime('%Y-%m-%d')} |",
f"| 时间跨度 | {(last - first).days} 天 |",
]
lines.append(f"")
# ── 约会检测 ──────────────────────────────
meetups = detect_meetups(photos)
if meetups:
lines += [
f"---",
f"",
f"## 🗓️ 可能的见面记录({len(meetups)} 次)",
f"",
f"> 以下是照片聚类检测到的时间+地点相近的拍摄记录,",
f"> 可能代表你们曾经在一起的时刻。",
f"",
]
for i, meetup in enumerate(meetups, 1):
dur_str = f"{meetup['duration_min']}分钟内" if meetup["duration_min"] > 0 else "同一时刻"
gps_str = ""
if meetup["gps"]:
g = meetup["gps"]
gps_str = f" 📍 坐标:{g['lat']}, {g['lon']}"
lines += [
f"### 第 {i}{meetup['date']}",
f"",
f"- 时间段:{meetup['start']} {meetup['end']}{dur_str}",
f"- 照片数:{meetup['photo_count']} 张",
]
if gps_str:
lines.append(gps_str)
lines.append(f"- 照片列表:")
for p in meetup["photos"][:5]:
t = p["datetime"].strftime("%H:%M") if p["datetime"] else "?"
lines.append(f" - `{p['rel']}`{t}")
if len(meetup["photos"]) > 5:
lines.append(f" - ... 共 {len(meetup['photos'])} 张")
lines.append(f"")
lines += [
f"**💡 使用建议**",
f"将这些照片路径告诉 Claude,让它描述照片内容,",
f"从中挖掘可以用于情话的细节(场景、表情、你们的互动)。",
f"",
]
else:
if with_time:
lines += [
f"---",
f"",
f"## 🗓️ 见面检测",
f"",
f"未检测到明确的时间/地点聚类,可能原因:",
f"- 照片缺少 EXIF 时间信息(截图、社交媒体下载的图通常无 EXIF)",
f"- 照片拍摄时间间隔超过 4 小时",
f"- 缺少 GPS 数据导致位置无法比对",
f"",
]
# ── 完整时间线 ────────────────────────────
if with_time:
lines += [
f"---",
f"",
f"## 📅 照片时间线",
f"",
]
# 按月分组
monthly: dict = defaultdict(list)
for p in with_time:
key = p["datetime"].strftime("%Y年%m月")
monthly[key].append(p)
for month, month_photos in sorted(monthly.items()):
lines.append(f"### {month}{len(month_photos)} 张)")
lines.append(f"")
for p in month_photos:
dt_str = p["datetime"].strftime("%m-%d %H:%M")
cam_str = f" 📱 {p['camera']}" if p["camera"] else ""
gps_str = f" 📍 {p['gps']['lat']:.4f}, {p['gps']['lon']:.4f}" if p["gps"] else ""
lines.append(f"- `{p['rel']}` 🕐 {dt_str}{cam_str}{gps_str}")
lines.append(f"")
# ── 无时间信息的照片 ─────────────────────
no_time = [p for p in photos if not p["datetime"]]
if no_time:
lines += [
f"---",
f"",
f"## ❓ 无时间信息的照片({len(no_time)} 张)",
f"",
f"> 这些照片缺少 EXIF 时间数据(常见于截图、从社交媒体保存的图片)。",
f"",
]
for p in no_time:
lines.append(f"- `{p['rel']}`{p['size_kb']} KB")
lines.append(f"")
lines += [
f"---",
f"",
f"## 📌 后续建议",
f"",
f"1. **有见面记录**:把照片路径告诉 Claude,让它描述内容,",
f" 提取可以用于情话的具体细节",
f"2. **无 EXIF 数据**:说明照片可能来自网络/截图,",
f" 这类照片更适合直接给 Claude 看内容",
f"3. **结合聊天记录**:对比见面日期和聊天记录,",
f" 看看见面当天和之后的消息有没有温度变化",
f"",
f"---",
f"",
f"*由 simp-skill · 追爱军师 生成*",
]
report = "\n".join(lines)
if output_path:
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
f.write(report)
print(f"✅ 报告已保存到 {output_path}")
return report
# ─────────────────────────────────────────────
# 主程序
# ─────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="simp-skill · 照片元数据分析器",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
python3 photo_analyzer.py --dir crushes/xiaomei/memories/photos
python3 photo_analyzer.py --dir ./photos --target 小美 --output report.md
python3 photo_analyzer.py --dir ./photos --gap 6 --radius 5
""",
)
parser.add_argument("--dir", required=True, help="照片目录路径")
parser.add_argument("--target", default="心上人", help="心上人的名字")
parser.add_argument("--output", "-o", help="输出报告路径(默认:打印到控制台)")
parser.add_argument("--gap", type=float, default=4.0,
help="约会检测时间窗口(小时,默认:4)")
parser.add_argument("--radius", type=float, default=2.0,
help="约会检测地点半径(公里,默认:2)")
args = parser.parse_args()
print(f"💝 simp-skill · 照片分析器")
print(f"📂 扫描目录:{args.dir}")
print(f"🎯 心上人:{args.target}")
if not PIL_AVAILABLE:
print(f"⚠️ Pillow 未安装,请运行:pip install Pillow")
print()
print()
report = generate_report(args.dir, args.target, args.output)
if not args.output:
print(report)
if __name__ == "__main__":
main()