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.
- package/.agents/skills/trip-moments/SKILL.md +255 -0
- package/.agents/skills/trip-moments/assets/examples/editorial-bridge-gemini.png +0 -0
- package/.agents/skills/trip-moments/assets/photo-abstract-editorial/PROVENANCE.md +6 -0
- package/.agents/skills/trip-moments/assets/photo-abstract-editorial/prompt-zh.md +246 -0
- package/.agents/skills/trip-moments/references/ai-apis.md +153 -0
- package/.agents/skills/trip-moments/references/amap-geo.md +51 -0
- package/.agents/skills/trip-moments/references/api-config.md +129 -0
- package/.agents/skills/trip-moments/references/editorial-poster.md +44 -0
- package/.agents/skills/trip-moments/scripts/aiclient.py +547 -0
- package/.agents/skills/trip-moments/scripts/check_env.py +119 -0
- package/.agents/skills/trip-moments/scripts/crop.py +367 -0
- package/.agents/skills/trip-moments/scripts/enhance.py +79 -0
- package/.agents/skills/trip-moments/scripts/exif_gps.py +170 -0
- package/.agents/skills/trip-moments/scripts/film.py +106 -0
- package/.agents/skills/trip-moments/scripts/findphotos.py +173 -0
- package/.agents/skills/trip-moments/scripts/grid9.py +134 -0
- package/.agents/skills/trip-moments/scripts/imquality.py +203 -0
- package/.agents/skills/trip-moments/scripts/polaroid.py +94 -0
- package/.agents/skills/trip-moments/scripts/poster.py +281 -0
- package/.agents/skills/trip-moments/scripts/requirements.txt +3 -0
- package/.agents/skills/trip-moments/scripts/retouch.py +112 -0
- package/.agents/skills/trip-moments/scripts/review.py +527 -0
- package/.agents/skills/trip-moments/scripts/route_map.py +337 -0
- package/.agents/skills/trip-moments/scripts/select9.py +389 -0
- package/.agents/skills/trip-moments/scripts/selftest.py +225 -0
- package/.agents/skills/trip-moments/scripts/styles.py +147 -0
- package/README.md +163 -0
- package/bin/install.js +399 -0
- package/install.cmd +42 -0
- package/install.ps1 +11 -0
- package/install.sh +12 -0
- 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())
|