Files
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 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()