trip-moments-skill 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/.agents/skills/trip-moments/SKILL.md +255 -0
  2. package/.agents/skills/trip-moments/assets/examples/editorial-bridge-gemini.png +0 -0
  3. package/.agents/skills/trip-moments/assets/photo-abstract-editorial/PROVENANCE.md +6 -0
  4. package/.agents/skills/trip-moments/assets/photo-abstract-editorial/prompt-zh.md +246 -0
  5. package/.agents/skills/trip-moments/references/ai-apis.md +153 -0
  6. package/.agents/skills/trip-moments/references/amap-geo.md +51 -0
  7. package/.agents/skills/trip-moments/references/api-config.md +129 -0
  8. package/.agents/skills/trip-moments/references/editorial-poster.md +44 -0
  9. package/.agents/skills/trip-moments/scripts/aiclient.py +547 -0
  10. package/.agents/skills/trip-moments/scripts/check_env.py +119 -0
  11. package/.agents/skills/trip-moments/scripts/crop.py +367 -0
  12. package/.agents/skills/trip-moments/scripts/enhance.py +79 -0
  13. package/.agents/skills/trip-moments/scripts/exif_gps.py +170 -0
  14. package/.agents/skills/trip-moments/scripts/film.py +106 -0
  15. package/.agents/skills/trip-moments/scripts/findphotos.py +173 -0
  16. package/.agents/skills/trip-moments/scripts/grid9.py +134 -0
  17. package/.agents/skills/trip-moments/scripts/imquality.py +203 -0
  18. package/.agents/skills/trip-moments/scripts/polaroid.py +94 -0
  19. package/.agents/skills/trip-moments/scripts/poster.py +281 -0
  20. package/.agents/skills/trip-moments/scripts/requirements.txt +3 -0
  21. package/.agents/skills/trip-moments/scripts/retouch.py +112 -0
  22. package/.agents/skills/trip-moments/scripts/review.py +527 -0
  23. package/.agents/skills/trip-moments/scripts/route_map.py +337 -0
  24. package/.agents/skills/trip-moments/scripts/select9.py +389 -0
  25. package/.agents/skills/trip-moments/scripts/selftest.py +225 -0
  26. package/.agents/skills/trip-moments/scripts/styles.py +147 -0
  27. package/README.md +163 -0
  28. package/bin/install.js +399 -0
  29. package/install.cmd +42 -0
  30. package/install.ps1 +11 -0
  31. package/install.sh +12 -0
  32. package/package.json +47 -0
@@ -0,0 +1,367 @@
1
+ #!/usr/bin/env python3
2
+ """Batch smart-crop photos for WeChat Moments (trip-moments skill).
3
+
4
+ Crops to a target aspect ratio using an energy-based window (keeps the
5
+ busiest, most detailed region instead of a blind center crop), then resizes
6
+ to a Moments-friendly width.
7
+
8
+ Composition is NOT fixed: with --ai-compose the vision model reviews all
9
+ photos in one call and suggests each photo's best ratio + framing note
10
+ (subject, negative space, horizon), then every photo is cropped at its own
11
+ suggested ratio. Falls back to --ratio when AI is unavailable.
12
+
13
+ usage:
14
+ python crop.py <file-or-folder>... [--ratio 3:4] [--ai-compose] [--width 1080]
15
+ [--all] [-o OUTDIR] [--quality 92]
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ import shutil
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ import numpy as np
26
+ from PIL import Image
27
+
28
+ if sys.platform == "win32":
29
+ try:
30
+ sys.stdout.reconfigure(encoding="utf-8")
31
+ sys.stderr.reconfigure(encoding="utf-8")
32
+ except Exception:
33
+ pass
34
+
35
+ sys.path.insert(0, str(Path(__file__).parent))
36
+ from imquality import list_images, load_rgb
37
+
38
+ RATIO_PRESETS = {"1:1", "3:4", "4:3", "16:9", "9:16"}
39
+
40
+
41
+ def _energy(gray: np.ndarray) -> np.ndarray:
42
+ gx, gy = np.gradient(gray)
43
+ return np.hypot(gx, gy)
44
+
45
+
46
+ def _best_window(profile: np.ndarray, width: int) -> int:
47
+ """Start index of the `width` window with the largest sum (energy)."""
48
+ if width >= len(profile):
49
+ return 0
50
+ prefix = np.concatenate(([0.0], np.cumsum(profile)))
51
+ sums = prefix[width:] - prefix[:-width]
52
+ return int(np.argmax(sums))
53
+
54
+
55
+ def _saliency_map(img: Image.Image) -> np.ndarray:
56
+ """Per-pixel saliency in [0, ~2] (smartcrop.js-style feature fusion).
57
+
58
+ detail (Laplacian edges on luma) + skin tones (highest weight: people are
59
+ the usual subject) + saturation, all under brightness constraints.
60
+ Near-black / blown-out pixels are dead zones: multiplied to ~zero so dead
61
+ bands never anchor the crop window.
62
+ """
63
+ rgb = np.asarray(img, dtype=np.float64) / 255.0
64
+ r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2]
65
+ luma = 0.2126 * r + 0.7152 * g + 0.0722 * b
66
+
67
+ lap = np.zeros_like(luma)
68
+ lap[1:-1, 1:-1] = (4 * luma[1:-1, 1:-1] - luma[:-2, 1:-1] - luma[2:, 1:-1]
69
+ - luma[1:-1, :-2] - luma[1:-1, 2:])
70
+ detail = np.clip(np.abs(lap) * 1.5, 0, 1)
71
+
72
+ skin_dist = np.sqrt((r - 0.78) ** 2 + (g - 0.57) ** 2 + (b - 0.44) ** 2)
73
+ skin = np.clip((0.30 - skin_dist) / 0.30, 0, 1)
74
+ skin *= np.clip((luma - 0.15) / 0.15, 0, 1) * np.clip((0.93 - luma) / 0.15, 0, 1)
75
+
76
+ sat = rgb.max(axis=2) - rgb.min(axis=2)
77
+ sat_score = np.clip(sat / 0.45, 0, 1) * np.clip((luma - 0.08) / 0.12, 0, 1) \
78
+ * np.clip((0.92 - luma) / 0.12 + 0.4, 0, 1)
79
+
80
+ dead = np.clip((0.055 - luma) / 0.055, 0, 1) # 1 where crushed black
81
+ score = (detail * 0.6 + skin * 1.2 + sat_score * 0.5) * (1.0 - dead)
82
+ return score
83
+
84
+
85
+ def _pos_weight(w: int, h: int) -> np.ndarray:
86
+ """Composition prior: thirds-line bonus + edge penalty + mild center bias."""
87
+ def axis(n: int) -> np.ndarray:
88
+ v = (np.arange(n) + 0.5) / n
89
+ bonus = np.zeros(n)
90
+ for t in (1.0 / 3, 2.0 / 3):
91
+ bonus += 0.30 * np.exp(-((v - t) / 0.07) ** 2)
92
+ bonus += 0.12 * np.exp(-((v - 0.5) / 0.25) ** 2)
93
+ edge = np.clip((0.06 - np.minimum(v, 1.0 - v)) / 0.06, 0, 1)
94
+ return 1.0 + bonus - 0.45 * edge
95
+ return np.outer(axis(h), axis(w))
96
+
97
+
98
+ def smart_crop(img: Image.Image, ratio: tuple[int, int], anchor: tuple[float, float] | None = None) -> Image.Image:
99
+ """Saliency-aware crop: build a feature map (detail/skin/saturation, dead
100
+ zones excluded), weight it by composition priors (thirds/center/edges),
101
+ then slide the crop window along the long axis to the max-saliency spot.
102
+ """
103
+ rw, rh = ratio
104
+ w, h = img.size
105
+ work = img.copy()
106
+ work.thumbnail((1024, 1024), Image.LANCZOS)
107
+ sal = _saliency_map(work)
108
+ sal *= _pos_weight(sal.shape[1], sal.shape[0])
109
+ scale = w / sal.shape[1]
110
+
111
+ if anchor is None:
112
+ anchor = _saliency_focus(sal)
113
+
114
+ target = rw / rh
115
+ ww, hh = work.size
116
+ if ww / hh > target: # too wide: slide horizontally
117
+ cw = min(ww, round(hh * target))
118
+ profile = sal.sum(axis=0)
119
+ w_idx = max(1, round(cw / scale))
120
+ x0 = _best_window_anchored(profile, w_idx,
121
+ anchor[0] * sal.shape[1] if anchor else None)
122
+ x0s = int(x0 * scale)
123
+ return img.crop((x0s, 0, x0s + cw, h))
124
+ ch = min(hh, round(ww / target))
125
+ profile = sal.sum(axis=1)
126
+ h_idx = max(1, round(ch / scale))
127
+ y0 = _best_window_anchored(profile, h_idx,
128
+ anchor[1] * sal.shape[0] if anchor else None)
129
+ y0s = int(y0 * scale)
130
+ return img.crop((0, y0s, w, y0s + ch))
131
+
132
+
133
+ def _saliency_focus(sal: np.ndarray) -> tuple[float, float]:
134
+ """Normalized (x, y) centroid of the top-5% saliency mass."""
135
+ total = float(sal.sum())
136
+ if total <= 0:
137
+ return (0.5, 0.5)
138
+ flat = sal.ravel()
139
+ k = max(1, int(flat.size * 0.05))
140
+ thr = np.partition(flat, -k)[-k]
141
+ ys, xs = np.nonzero(sal >= thr)
142
+ wts = sal[sal >= thr]
143
+ cx = float((xs * wts).sum() / wts.sum()) / sal.shape[1]
144
+ cy = float((ys * wts).sum() / wts.sum()) / sal.shape[0]
145
+ return (cx, cy)
146
+
147
+
148
+ def _best_window_anchored(profile: np.ndarray, width: int, anchor: float | None) -> int:
149
+ """Best-saliency window; with an anchor, restricted to windows covering it."""
150
+ n = len(profile)
151
+ if width >= n:
152
+ return 0
153
+ prefix = np.concatenate(([0.0], np.cumsum(profile)))
154
+ sums = prefix[width:] - prefix[:-width]
155
+ if anchor is None:
156
+ return int(np.argmax(sums))
157
+ a = min(n - 1, max(0, int(anchor)))
158
+ lo, hi = max(0, a - width + 1), min(a, n - width)
159
+ if lo > hi:
160
+ return int(np.argmax(sums))
161
+ return lo + int(np.argmax(sums[lo:hi + 1]))
162
+
163
+
164
+ def smart_crop_centered(img: Image.Image, ratio: tuple[int, int],
165
+ anchor: tuple[float, float]) -> Image.Image:
166
+ """Fallback refit: window dead-centered on the subject, saliency ignored."""
167
+ rw, rh = ratio
168
+ w, h = img.size
169
+ target = rw / rh
170
+ if w / h > target:
171
+ cw = min(w, round(h * target))
172
+ x0 = min(w - cw, max(0, round(anchor[0] * w - cw / 2)))
173
+ return img.crop((x0, 0, x0 + cw, h))
174
+ ch = min(h, round(w / target))
175
+ y0 = min(h - ch, max(0, round(anchor[1] * h - ch / 2)))
176
+ return img.crop((0, y0, w, y0 + ch))
177
+
178
+
179
+ VERIFY_PROMPT = """你将看到成对的图片,每对两张:第一张是原图,第二张是按建议构图裁剪后的结果。
180
+ 请严格核查以下两点:
181
+ 1. 主体完整性:裁剪后画面主体(人物/建筑/景物等)是否完整保留,没有被切掉一半或肢体只剩残缺;
182
+ 2. 构图合理性:构图是否自然美观、主体突出,没有灾难性的严重切边。
183
+ 原图本身如果已经有残缺,请按照原图所具备的完整度来核定。
184
+ 只输出 JSON 数组,每对图片一个对象:
185
+ [{"i":1,"主体完整":true,"构图合理":true,"原因":"<=14字说明,没有问题则空着"}]"""
186
+
187
+
188
+ def verify_crops(pairs: list[tuple[Path, Path]]) -> list[tuple[Path, Path, str]]:
189
+ """Self-review: original vs cropped, per pair. Returns failing pairs+reason."""
190
+ from aiclient import AIError, json_from_reply, vision_client
191
+ from imquality import thumbnail_data_url
192
+
193
+ urls = []
194
+ for src, dst in pairs:
195
+ urls.append(thumbnail_data_url(src, max_side=640))
196
+ urls.append(thumbnail_data_url(dst, max_side=640))
197
+ try:
198
+ client, model = vision_client()
199
+ reply = client.chat_vision(urls, VERIFY_PROMPT, model=model, timeout=180)
200
+ items = json_from_reply(reply)
201
+ if not isinstance(items, list):
202
+ return []
203
+ problems = []
204
+ for item, pair in zip(items, pairs):
205
+ if not (item.get("主体完整", True) and item.get("构图合理", True)):
206
+ problems.append((pair[0], pair[1], str(item.get("原因", "")).strip()))
207
+ if problems:
208
+ print(f"[warn] 视觉复核发现 {len(problems)} 处裁剪截断风险,自动居中回退重裁")
209
+ else:
210
+ print(f"[ok] 视觉复核通过:{len(pairs)} 组裁剪均主体完整")
211
+ return problems
212
+ except (AIError, ValueError) as exc:
213
+ print(f"[warn] 视觉复核未执行: {exc}")
214
+ return []
215
+
216
+
217
+ def parse_ratio(text: str) -> tuple[int, int]:
218
+ try:
219
+ w, h = text.lower().split(":", 1)
220
+ return int(w), int(h)
221
+ except ValueError:
222
+ raise SystemExit(f"[error] 非法比例格式 '{text}' (支持如 3:4, 16:9, 1:1)")
223
+
224
+
225
+ COMPOSE_PROMPT = """请通览这组旅游照片的构图与意境。这些照片要发朋友圈,请综合判断每张最适合的画幅比例。
226
+ 可选比例:1:1 (正方形局部)、3:4 (竖构图标准朋友圈画幅)、4:3 (横构图风景)、
227
+ 16:9 (超宽风景视野)、9:16 (通天长构图,适合极高建筑或前后景拉伸)、
228
+ **原图** (原图构图已经极好或地平线非常舒展,不需要任何裁剪,保留原样)。
229
+ 判断依据:主体形态、视觉延伸方向、故事重心位置、地平线;只有在裁剪后能明显提升视觉冲击力时才建议裁剪。
230
+ 找出每张照片的“核心视觉主体”(例如人物/脸/手/主体建筑物),并给出该主体中心点的
231
+ 百分比坐标(x 从左到右,y 从上到下,0-100 整数)。如果照片是人像写真(带头肩),
232
+ 请确保坐标落在主体面部或上半身中心。
233
+
234
+ 只输出严格的 JSON 数组,每张照片一个对象:
235
+ [{"i":1,"比例":"3:4 或 原图","原因":"<=16字","主体":"说明","主体x":50,"主体y":45}]"""
236
+
237
+
238
+ def ai_compose(files: list[Path]) -> dict[Path, dict]:
239
+ """One batched vision call -> per-photo {ratio, reason, anchor}. Never raises."""
240
+ from aiclient import AIError, json_from_reply, vision_client
241
+ from imquality import thumbnail_data_url
242
+
243
+ urls = [thumbnail_data_url(p, max_side=768) for p in files]
244
+ try:
245
+ client, model = vision_client()
246
+ reply = client.chat_vision(urls, COMPOSE_PROMPT, model=model, timeout=120)
247
+ items = json_from_reply(reply)
248
+ if not isinstance(items, list):
249
+ raise ValueError("not a list")
250
+ out: dict[Path, dict] = {}
251
+ for item, src in zip(items, files):
252
+ raw_ratio = str(item.get("比例", "3:4"))
253
+ keep = any(w in raw_ratio for w in ("原图", "保留", "不裁", "原始"))
254
+ if keep:
255
+ ratio = None # None = keep original
256
+ else:
257
+ try:
258
+ ratio = parse_ratio(raw_ratio)
259
+ except SystemExit:
260
+ ratio = (3, 4)
261
+ try:
262
+ ax = min(100.0, max(0.0, float(item.get("主体x", 50)))) / 100
263
+ ay = min(100.0, max(0.0, float(item.get("主体y", 50)))) / 100
264
+ except (TypeError, ValueError):
265
+ ax, ay = 0.5, 0.5
266
+ out[src] = {"ratio": ratio, "reason": str(item.get("原因", "")).strip(),
267
+ "subject": str(item.get("主体", "")).strip(), "anchor": (ax, ay)}
268
+ for f in files:
269
+ out.setdefault(f, {"ratio": (3, 4), "reason": "模型未返回建议,默认采用 3:4",
270
+ "subject": "", "anchor": (0.5, 0.5)})
271
+ print(f"[ok] AI 智能构图完成 {len(out)}/{len(files)} 张 (模型: {model})")
272
+ return out
273
+ except (AIError, ValueError) as exc:
274
+ print(f"[warn] AI 智能构图失败,降级为默认 --ratio 裁剪: {exc}")
275
+ return {}
276
+
277
+
278
+ def main() -> int:
279
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
280
+ ap.add_argument("inputs", nargs="+", help="图片文件或文件夹路径")
281
+ ap.add_argument("--ratio", default="3:4", help="目标长宽比 W:H (默认 3:4;开启 --ai-compose 时作为兜底)")
282
+ ap.add_argument("--ai-compose", action="store_true",
283
+ help="启用大模型多模态智能构图与画幅推荐")
284
+ ap.add_argument("--width", type=int, default=1080, help="输出宽度像素 (默认 1080)")
285
+ ap.add_argument("--all", action="store_true", help=f"生成全部预设比例: {sorted(RATIO_PRESETS)}")
286
+ ap.add_argument("-o", "--outdir", help="输出目录 (默认 <input dir>/trip-moments-output/cropped)")
287
+ ap.add_argument("--quality", type=int, default=92)
288
+ args = ap.parse_args()
289
+
290
+ ratios = [(1, 1), (3, 4), (4, 3), (16, 9), (9, 16)] if args.all else [parse_ratio(args.ratio)]
291
+
292
+ files: list[Path] = []
293
+ for item in args.inputs:
294
+ p = Path(item)
295
+ if not p.exists():
296
+ print(f"[warn] 路径不存在,已跳过: {p}")
297
+ continue
298
+ files.extend(list_images(p))
299
+ if not files:
300
+ print("[error] 未找到任何有效图片文件")
301
+ return 1
302
+
303
+ outroot = Path(args.outdir) if args.outdir else files[0].parent / "trip-moments-output" / "cropped"
304
+ outroot.mkdir(parents=True, exist_ok=True)
305
+
306
+ suggestions: dict[Path, dict] = {}
307
+ if args.ai_compose:
308
+ suggestions = ai_compose(files)
309
+ if not suggestions:
310
+ print("[error] --ai-compose 执行失败,已中止(避免生成未按 AI 建议裁剪的文件)。\n"
311
+ "请检查网络或 AI 配置,或直接指定 --ratio 进行局部裁剪。")
312
+ return 2
313
+ compose_path = outroot / "compose.json"
314
+ compose_path.write_text(json.dumps({
315
+ src.name: {"ratio": list(s["ratio"]) if s["ratio"] else None,
316
+ "subject": s["subject"], "anchor": s["anchor"],
317
+ "reason": s["reason"]}
318
+ for src, s in suggestions.items()
319
+ }, ensure_ascii=False, indent=1), encoding="utf-8")
320
+ print(f"[ok] 构图建议已保存至 {compose_path.name} (grid9 拼图将自动复用主体锚点)")
321
+
322
+ cropped_pairs: list[tuple[Path, Path]] = []
323
+ kept_originals = 0
324
+ for src in files:
325
+ img = load_rgb(src)
326
+ if suggestions:
327
+ s = suggestions[src]
328
+ ratio, why = s["ratio"], s["reason"]
329
+ who = f", 主体:{s['subject']}" if s["subject"] else ""
330
+ if ratio is None:
331
+ dst = outroot / f"{src.stem}_原图{src.suffix.lower()}"
332
+ shutil.copy2(src, dst)
333
+ kept_originals += 1
334
+ print(f"{src.name}: 原图保留 ({why}{who}) -> {dst.name}")
335
+ continue
336
+ print(f"{src.name}: 裁剪建议 {ratio[0]}:{ratio[1]} ({why}{who})")
337
+ ratios_this = [ratio]
338
+ anchor = s["anchor"]
339
+ else:
340
+ ratios_this = ratios
341
+ anchor = None
342
+ for ratio in ratios_this:
343
+ cropped = smart_crop(img, ratio, anchor=anchor)
344
+ out_w = args.width
345
+ out_h = round(out_w * ratio[1] / ratio[0])
346
+ cropped = cropped.resize((out_w, out_h), Image.LANCZOS)
347
+ dst = outroot / f"{src.stem}_{ratio[0]}x{ratio[1]}.jpg"
348
+ cropped.save(dst, "JPEG", quality=args.quality)
349
+ cropped_pairs.append((src, dst))
350
+ print(f" -> {dst.name} ({out_w}x{out_h})")
351
+
352
+ if suggestions and cropped_pairs:
353
+ for src, dst, prob in verify_crops(cropped_pairs):
354
+ refit = smart_crop_centered(load_rgb(src), suggestions[src]["ratio"],
355
+ suggestions[src]["anchor"])
356
+ out_w = args.width
357
+ ratio = suggestions[src]["ratio"]
358
+ refit = refit.resize((out_w, round(out_w * ratio[1] / ratio[0])), Image.LANCZOS)
359
+ refit.save(dst, "JPEG", quality=args.quality)
360
+ print(f" [refit] {src.name}: 发现裁剪问题({prob or '主体截断'}),已居中主体重新裁剪 -> {dst.name}")
361
+ tail = f" (其中 {kept_originals} 张保留原图未裁)" if kept_originals else ""
362
+ print(f"[ok] {len(files)} 张图片处理完毕 -> {outroot}{tail}")
363
+ return 0
364
+
365
+
366
+ if __name__ == "__main__":
367
+ raise SystemExit(main())
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env python3
2
+ """Free local photo enhancement — the zero-cost base polish option (trip-moments skill).
3
+
4
+ Conservative, non-destructive polish: auto-contrast, gentle saturation and
5
+ sharpness lift, exposure-aware brightness. Pixels only get nudged, never
6
+ repainted (unlike AI editing, content is guaranteed unchanged). No API key.
7
+
8
+ For heavier retouching (去路人/换天气) use retouch.py instead — that repaints.
9
+
10
+ usage:
11
+ python enhance.py <files-or-folder> [-o OUTDIR] [--strength 1.0]
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from PIL import ImageEnhance, ImageOps
20
+
21
+ if sys.platform == "win32":
22
+ try:
23
+ sys.stdout.reconfigure(encoding="utf-8")
24
+ sys.stderr.reconfigure(encoding="utf-8")
25
+ except Exception:
26
+ pass
27
+
28
+ sys.path.insert(0, str(Path(__file__).parent))
29
+ from imquality import exposure_stats, list_images, load_rgb
30
+
31
+
32
+ def enhance(img, strength: float):
33
+ img = ImageOps.autocontrast(img, cutoff=1)
34
+ img = ImageEnhance.Color(img).enhance(1 + 0.10 * strength)
35
+ expo = exposure_stats(img)
36
+ if expo["mean"] < 90: # 欠曝提亮,越暗提亮越多
37
+ img = ImageEnhance.Brightness(img).enhance(1 + 0.18 * strength * min(1.0, (90 - expo["mean"]) / 60))
38
+ elif expo["mean"] > 195: # 过曝压高光
39
+ img = ImageEnhance.Brightness(img).enhance(1 - 0.10 * strength)
40
+ img = ImageEnhance.Sharpness(img).enhance(1 + 0.25 * strength)
41
+ return img
42
+
43
+
44
+ def main() -> int:
45
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
46
+ ap.add_argument("inputs", nargs="+", help="图片文件或文件夹路径")
47
+ ap.add_argument("--strength", type=float, default=1.0,
48
+ help="调节强度 0.5=微调 / 1.0=标准 / 1.5=较强 (默认 1.0)")
49
+ ap.add_argument("-o", "--outdir", help="输出目录 (默认 <input dir>/trip-moments-output/enhanced)")
50
+ args = ap.parse_args()
51
+
52
+ files: list[Path] = []
53
+ for item in args.inputs:
54
+ p = Path(item)
55
+ if not p.exists():
56
+ print(f"[warn] 路径不存在,已跳过: {p}")
57
+ continue
58
+ files.extend(list_images(p))
59
+ if not files:
60
+ print("[error] 未找到任何有效图片文件")
61
+ return 1
62
+
63
+ outdir = Path(args.outdir) if args.outdir else files[0].parent / "trip-moments-output" / "enhanced"
64
+ outdir.mkdir(parents=True, exist_ok=True)
65
+ for src in files:
66
+ img = enhance(load_rgb(src), args.strength)
67
+ dst = outdir / f"{src.stem}_enh.jpg"
68
+ img.save(dst, "JPEG", quality=93)
69
+ print(f" [ok] {src.name} -> {dst.name}")
70
+ print(f"[done] {len(files)} 张照片完成本地画质提升 (零成本、保真) -> {outdir}")
71
+ return 0
72
+
73
+
74
+ if __name__ == "__main__":
75
+ raise SystemExit(main())
76
+
77
+
78
+ if __name__ == "__main__":
79
+ raise SystemExit(main())
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env python3
2
+ """Extract EXIF GPS + capture time from photos into JSON (trip-moments skill).
3
+
4
+ Feeds route_map.py: outputs a time-sorted list of {file, time, lat, lon} plus
5
+ GCJ-02 coordinates (the datum AMap uses — raw EXIF is WGS-84 and would sit
6
+ ~300-600m off a Chinese map if used directly).
7
+
8
+ usage:
9
+ python exif_gps.py <folder-or-files...> [-o gps.json] [--wgs84]
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import math
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from PIL import Image
20
+
21
+ if sys.platform == "win32":
22
+ try:
23
+ sys.stdout.reconfigure(encoding="utf-8")
24
+ sys.stderr.reconfigure(encoding="utf-8")
25
+ except Exception:
26
+ pass
27
+
28
+ sys.path.insert(0, str(Path(__file__).parent))
29
+ from imquality import list_images
30
+
31
+ # --- WGS-84 -> GCJ-02 (Mars coordinates) -----------------------------------
32
+ _A = 6378245.0
33
+ _EE = 0.00669342162296594323
34
+
35
+
36
+ def _out_of_china(lon: float, lat: float) -> bool:
37
+ return not (73.66 < lon < 135.05 and 3.86 < lat < 53.55)
38
+
39
+
40
+ def _tlat(x: float, y: float) -> float:
41
+ r = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * math.sqrt(abs(x))
42
+ r += (20.0 * math.sin(6.0 * x * math.pi) + 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0
43
+ r += (20.0 * math.sin(y * math.pi) + 40.0 * math.sin(y / 3.0 * math.pi)) * 2.0 / 3.0
44
+ r += (160.0 * math.sin(y / 12.0 * math.pi) + 320.0 * math.sin(y * math.pi / 30.0)) * 2.0 / 3.0
45
+ return r
46
+
47
+
48
+ def _tlon(x: float, y: float) -> float:
49
+ r = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * math.sqrt(abs(x))
50
+ r += (20.0 * math.sin(6.0 * x * math.pi) + 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0
51
+ r += (20.0 * math.sin(x * math.pi) + 40.0 * math.sin(x / 3.0 * math.pi)) * 2.0 / 3.0
52
+ r += (150.0 * math.sin(x / 12.0 * math.pi) + 300.0 * math.sin(x / 30.0 * math.pi)) * 2.0 / 3.0
53
+ return r
54
+
55
+
56
+ def wgs84_to_gcj02(lon: float, lat: float) -> tuple[float, float]:
57
+ """Transform GPS coordinates from WGS-84 to GCJ-02 (for Chinese maps)."""
58
+ if _out_of_china(lon, lat):
59
+ return lon, lat
60
+ dlat = _tlat(lon - 105.0, lat - 35.0)
61
+ dlon = _tlon(lon - 105.0, lat - 35.0)
62
+ radlat = lat / 180.0 * math.pi
63
+ magic = 1.0 - _EE * math.sin(radlat) ** 2
64
+ sqrtmagic = math.sqrt(magic)
65
+ dlat = (dlat * 180.0) / ((_A * (1 - _EE)) / (magic * sqrtmagic) * math.pi)
66
+ dlon = (dlon * 180.0) / (_A / sqrtmagic * math.cos(radlat) * math.pi)
67
+ return lon + dlon, lat + dlat
68
+
69
+
70
+ # --- EXIF reading -----------------------------------------------------------
71
+ def _rational_to_float(v) -> float:
72
+ """EXIF rationals arrive as IFDRational or tuples of (num, den)."""
73
+ try:
74
+ return float(v)
75
+ except (TypeError, ValueError):
76
+ pass
77
+ if isinstance(v, (tuple, list)) and len(v) >= 2:
78
+ try:
79
+ return float(v[0]) / float(v[1])
80
+ except (ZeroDivisionError, TypeError, ValueError):
81
+ return 0.0
82
+ return 0.0
83
+
84
+
85
+ def _dms_to_deg(v, ref: str) -> float:
86
+ """Convert degrees/minutes/seconds list/tuple to decimal degrees."""
87
+ if not isinstance(v, (list, tuple)) or len(v) == 0:
88
+ return 0.0
89
+ deg = _rational_to_float(v[0])
90
+ min_ = _rational_to_float(v[1]) if len(v) > 1 else 0.0
91
+ sec_ = _rational_to_float(v[2]) if len(v) > 2 else 0.0
92
+ val = deg + min_ / 60.0 + sec_ / 3600.0
93
+ return -val if ref.strip().upper() in ("S", "W") else val
94
+
95
+
96
+ def read_gps(path: str | Path) -> dict | None:
97
+ """Read EXIF metadata and extract GPS coordinates and capture time."""
98
+ try:
99
+ img = Image.open(path)
100
+ exif = img.getexif()
101
+ except Exception:
102
+ return None
103
+ gps = exif.get_ifd(0x8825)
104
+ if not gps:
105
+ return None
106
+
107
+ def ref(tag, default):
108
+ v = gps.get(tag)
109
+ if isinstance(v, bytes):
110
+ return v.decode(errors="ignore").strip() or default
111
+ return str(v).strip() if v else default
112
+
113
+ lat_v, lon_v = gps.get(2), gps.get(4)
114
+ if not lat_v or not lon_v:
115
+ return None
116
+ lat = _dms_to_deg(list(lat_v), ref(1, "N"))
117
+ lon = _dms_to_deg(list(lon_v), ref(3, "E"))
118
+
119
+ taken = None
120
+ sub = exif.get_ifd(0x8769)
121
+ for tag in (36867, 36868): # DateTimeOriginal, DateTimeDigitized
122
+ if sub.get(tag):
123
+ taken = str(sub.get(tag))
124
+ break
125
+ return {"file": Path(path).name, "time": taken, "lat": lat, "lon": lon}
126
+
127
+
128
+ def main() -> int:
129
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
130
+ ap.add_argument("inputs", nargs="+", help="image files or folders")
131
+ ap.add_argument("-o", "--output", help="output JSON path (default <input dir>/trip-moments-output/gps.json)")
132
+ ap.add_argument("--wgs84", action="store_true", help="keep raw WGS-84, skip GCJ-02 conversion")
133
+ args = ap.parse_args()
134
+
135
+ files: list[Path] = []
136
+ for item in args.inputs:
137
+ p = Path(item)
138
+ if not p.exists():
139
+ print(f"[warn] 未找到路径, 已跳过: {p}")
140
+ continue
141
+ files.extend(list_images(p))
142
+ if not files:
143
+ print("[error] 未找到任何图片文件")
144
+ return 1
145
+
146
+ points = []
147
+ for f in files:
148
+ rec = read_gps(f)
149
+ if rec:
150
+ if not args.wgs84:
151
+ rec["lon_gcj"], rec["lat_gcj"] = wgs84_to_gcj02(rec["lon"], rec["lat"])
152
+ points.append(rec)
153
+
154
+ points.sort(key=lambda r: (r.get("time") or "", r["file"]))
155
+ if points:
156
+ out = Path(args.output) if args.output else files[0].parent / "trip-moments-output" / "gps.json"
157
+ out.parent.mkdir(parents=True, exist_ok=True)
158
+ out.write_text(json.dumps(points, ensure_ascii=False, indent=2), encoding="utf-8")
159
+ print(f"[ok] {len(points)}/{len(files)} 张照片提取到 EXIF GPS 信息 -> {out}")
160
+ for p in points:
161
+ lon = p.get("lon_gcj", p["lon"])
162
+ lat = p.get("lat_gcj", p["lat"])
163
+ print(f" {p['file']} {p.get('time') or '时间未知'} {lat:.5f},{lon:.5f}")
164
+ else:
165
+ print("[warn] 照片不含 EXIF GPS 数据,后续路线图可直接通过文本站点列表生成")
166
+ return 0
167
+
168
+
169
+ if __name__ == "__main__":
170
+ raise SystemExit(main())