@yujp_vc/ppt-assistant 0.0.0-stage → 1.5.2
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/LICENSE +21 -0
- package/README.md +131 -2
- package/asset_catalog.py +159 -0
- package/assets/base_template.pptx +0 -0
- package/assets/cover_deco.png +0 -0
- package/assets/logo.png +0 -0
- package/assets/slide2_right.png +0 -0
- package/assets/theme_apricot.jpg +0 -0
- package/assets/theme_blush.jpg +0 -0
- package/assets/theme_coral.jpg +0 -0
- package/assets/theme_glow.jpg +0 -0
- package/assets/theme_mauve.jpg +0 -0
- package/assets/theme_peach.jpg +0 -0
- package/assets/theme_rose.jpg +0 -0
- package/assets/theme_sunset.jpg +0 -0
- package/assets/topright_deco.png +0 -0
- package/build_deck.py +2288 -0
- package/charts.py +329 -0
- package/check.py +236 -0
- package/config.json +5 -0
- package/contact_sheet.py +88 -0
- package/cordis.patch.yml +457 -0
- package/deck_baseline.py +335 -0
- package/delivery.py +143 -0
- package/density-recipe.md +110 -0
- package/example.json +26 -0
- package/formula.py +125 -0
- package/gen_image.py +395 -0
- package/generate_consensus.py +892 -0
- package/generate_ppt.py +1356 -0
- package/grasp_evidence.py +150 -0
- package/image_check.py +148 -0
- package/image_fetch.py +253 -0
- package/ingest.py +93 -0
- package/media.py +470 -0
- package/package.json +68 -4
- package/preset.yml +2 -0
- package/quality_check.py +196 -0
- package/render_ppt.py +91 -0
- package/requirements.txt +7 -0
- package/restyle.py +80 -0
- package/run_pptfast.sh +27 -0
- package/run_py.sh +43 -0
- package/slide_ops.py +160 -0
- package/slide_patch.py +153 -0
- package/speaker_notes.py +245 -0
- package/style-recipes.md +166 -0
- package/video_gif.py +164 -0
- package/vision_check.py +79 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Read numeric grasp evidence and project it without running workspace code."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from PIL import Image
|
|
9
|
+
|
|
10
|
+
# Shared sequential palette for raw and completed depth, including the legend.
|
|
11
|
+
DEPTH_COLORS = np.array([[68, 1, 84], [59, 82, 139], [33, 145, 140],
|
|
12
|
+
[94, 201, 98], [253, 231, 37]], dtype=float)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def depth_colors(values):
|
|
16
|
+
"""Map normalized values in [0, 1] to the same RGB palette."""
|
|
17
|
+
values = np.clip(values, 0, 1)
|
|
18
|
+
stops = np.linspace(0, 1, len(DEPTH_COLORS))
|
|
19
|
+
return np.stack([np.interp(values, stops, DEPTH_COLORS[:, i])
|
|
20
|
+
for i in range(3)], axis=-1).astype(np.uint8)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def depth_pair(before: Path, after: Path, crop: list[int], limits: list[float]):
|
|
24
|
+
"""Return two atomic depth images using identical pixel ROI and meter limits."""
|
|
25
|
+
arrays = [np.load(path, allow_pickle=False) for path in (before, after)]
|
|
26
|
+
if arrays[0].ndim != 2 or arrays[0].shape != arrays[1].shape:
|
|
27
|
+
raise ValueError("depth pair requires matching two-dimensional arrays")
|
|
28
|
+
left, top, right, bottom = crop
|
|
29
|
+
height, width = arrays[0].shape
|
|
30
|
+
if not (0 <= left < right <= width and 0 <= top < bottom <= height):
|
|
31
|
+
raise ValueError("depth crop exceeds source dimensions")
|
|
32
|
+
low, high = limits
|
|
33
|
+
if not np.isfinite([low, high]).all() or not 0 < low < high:
|
|
34
|
+
raise ValueError("depth pair requires increasing positive meter limits")
|
|
35
|
+
pictures = []
|
|
36
|
+
for array in arrays:
|
|
37
|
+
roi = array[top:bottom, left:right]
|
|
38
|
+
valid = np.isfinite(roi) & (roi > 0)
|
|
39
|
+
normalized = np.zeros(roi.shape, dtype=float)
|
|
40
|
+
normalized[valid] = (roi[valid] - low) / (high - low)
|
|
41
|
+
rgb = depth_colors(normalized)
|
|
42
|
+
rgb[~valid] = [190, 190, 190]
|
|
43
|
+
pictures.append(Image.fromarray(rgb))
|
|
44
|
+
return pictures
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def gripper_projection(execution: Path, calibration: Path, tip_z_m=.1034, finger_length_m=.04,
|
|
48
|
+
finger_width_m=.02, finger_thickness_m=.01):
|
|
49
|
+
"""Project a selected pose and predicted jaw opening into source RGB pixels.
|
|
50
|
+
|
|
51
|
+
The wireframe follows the recorded camera transform. Finger length and
|
|
52
|
+
tip offset describe the display geometry, not a collision or safety test.
|
|
53
|
+
"""
|
|
54
|
+
record = json.loads(execution.read_text(encoding="utf-8"))
|
|
55
|
+
camera = json.loads(calibration.read_text(encoding="utf-8"))
|
|
56
|
+
return project_gripper(record, camera, tip_z_m, finger_length_m,
|
|
57
|
+
finger_width_m, finger_thickness_m)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def candidate_projections(response: Path, calibration: Path, count=None):
|
|
61
|
+
"""Project the highest-score saved candidates with the selected-pose geometry."""
|
|
62
|
+
record = json.loads(response.read_text(encoding="utf-8"))
|
|
63
|
+
camera = json.loads(calibration.read_text(encoding="utf-8"))
|
|
64
|
+
grasps = record["grasps"]
|
|
65
|
+
candidates = []
|
|
66
|
+
if count is not None and (type(count) is not int or not 1 <= count <= len(grasps.get("scores", {}).get(next(iter(grasps["scores"]), ""), []))):
|
|
67
|
+
raise ValueError("candidate display count is outside the recorded candidates")
|
|
68
|
+
for group, poses in grasps["pred_grasps_cam"].items():
|
|
69
|
+
scores, openings = grasps["scores"][group], grasps["gripper_openings"][group]
|
|
70
|
+
if len(poses) != len(scores) or len(poses) != len(openings):
|
|
71
|
+
raise ValueError("candidate poses, scores and openings must have matching lengths")
|
|
72
|
+
for index, (pose, score, opening) in enumerate(zip(poses, scores, openings)):
|
|
73
|
+
if type(score) not in {int, float} or not np.isfinite(score):
|
|
74
|
+
raise ValueError("candidate scores must be finite numbers")
|
|
75
|
+
candidates.append((score, group, index, pose, opening))
|
|
76
|
+
if len(candidates) != grasps["total_grasps"] or not candidates:
|
|
77
|
+
raise ValueError("candidate arrays must match the recorded nonzero total")
|
|
78
|
+
projections = []
|
|
79
|
+
ranked = sorted(candidates, key=lambda c: -c[0])
|
|
80
|
+
for score, group, index, pose, opening in ranked if count is None else ranked[:count]:
|
|
81
|
+
projection = project_gripper({"T_camera_cgn_gripper": pose,
|
|
82
|
+
"gripper_opening_m": opening}, camera)
|
|
83
|
+
projection.update(score=score, group=group, index=index)
|
|
84
|
+
projections.append(projection)
|
|
85
|
+
return {"total": len(candidates), "projections": projections}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def gated_candidate_projections(execution: Path, calibration: Path):
|
|
89
|
+
"""Project every saved candidate and retain its accepted/rejected status."""
|
|
90
|
+
record = json.loads(execution.read_text(encoding="utf-8"))
|
|
91
|
+
camera = json.loads(calibration.read_text(encoding="utf-8"))
|
|
92
|
+
accepted = record.get("candidate_acceptances", [])
|
|
93
|
+
rejected = record.get("candidate_rejections", [])
|
|
94
|
+
candidates = [(item, "accepted") for item in accepted] + [(item, "rejected") for item in rejected]
|
|
95
|
+
expected = record.get("candidates_evaluated")
|
|
96
|
+
if type(expected) is not int or expected <= 0 or len(candidates) != expected:
|
|
97
|
+
raise ValueError("candidate gate record must contain every evaluated candidate")
|
|
98
|
+
projections = []
|
|
99
|
+
for item, status in candidates:
|
|
100
|
+
pose = item.get("T_camera_cgn_gripper")
|
|
101
|
+
opening = item.get("gripper_opening_m")
|
|
102
|
+
if pose is None or opening is None:
|
|
103
|
+
raise ValueError("candidate gate record is missing pose or opening")
|
|
104
|
+
projection = project_gripper({"T_camera_cgn_gripper": pose,
|
|
105
|
+
"gripper_opening_m": opening}, camera)
|
|
106
|
+
projection.update(status=status, index=item.get("candidate_index"),
|
|
107
|
+
score=item.get("candidate_score"))
|
|
108
|
+
projections.append(projection)
|
|
109
|
+
return {"total": expected, "accepted": len(accepted),
|
|
110
|
+
"rejected": len(rejected), "projections": projections}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def project_gripper(record, camera, tip_z_m=.1034, finger_length_m=.04,
|
|
114
|
+
finger_width_m=.02, finger_thickness_m=.01):
|
|
115
|
+
"""Project one recorded pose with common display geometry for every layout."""
|
|
116
|
+
transform = np.asarray(record["T_camera_cgn_gripper"], dtype=float)
|
|
117
|
+
intrinsic = np.asarray(camera["color_k"], dtype=float)
|
|
118
|
+
opening = float(record["gripper_opening_m"])
|
|
119
|
+
if (transform.shape != (4, 4) or intrinsic.shape != (3, 3)
|
|
120
|
+
or not np.isfinite(transform).all() or not np.isfinite(intrinsic).all()
|
|
121
|
+
or not np.isfinite(opening) or not 0 <= opening <= .2
|
|
122
|
+
or not 0 < finger_length_m < tip_z_m
|
|
123
|
+
or not 0 < finger_width_m < .1 or not 0 < finger_thickness_m < .1):
|
|
124
|
+
raise ValueError("invalid recorded gripper pose or display geometry")
|
|
125
|
+
base_z = tip_z_m - finger_length_m
|
|
126
|
+
local = np.array([[-opening / 2, 0, base_z], [-opening / 2, 0, tip_z_m],
|
|
127
|
+
[opening / 2, 0, base_z], [opening / 2, 0, tip_z_m],
|
|
128
|
+
[0, 0, tip_z_m]])
|
|
129
|
+
edges = [(0, 1), (0, 2), (2, 3)]
|
|
130
|
+
# Draw each finger as a wireframe volume so an oblique camera view does
|
|
131
|
+
# not reduce the grasp display to three nearly coincident center lines.
|
|
132
|
+
for low_x, high_x in [(-opening / 2 - finger_thickness_m, -opening / 2),
|
|
133
|
+
(opening / 2, opening / 2 + finger_thickness_m)]:
|
|
134
|
+
start = len(local)
|
|
135
|
+
corners = [[x, y, z] for x in (low_x, high_x)
|
|
136
|
+
for y in (-finger_width_m / 2, finger_width_m / 2)
|
|
137
|
+
for z in (base_z, tip_z_m)]
|
|
138
|
+
local = np.concatenate([local, np.array(corners)])
|
|
139
|
+
for a in range(8):
|
|
140
|
+
for axis_bit in (1, 2, 4):
|
|
141
|
+
b = a ^ axis_bit
|
|
142
|
+
if a < b:
|
|
143
|
+
edges.append((start + a, start + b))
|
|
144
|
+
points = local @ transform[:3, :3].T + transform[:3, 3]
|
|
145
|
+
if (points[:, 2] <= 0).any():
|
|
146
|
+
raise ValueError("gripper projection requires positive camera depth")
|
|
147
|
+
pixels = points @ intrinsic.T
|
|
148
|
+
pixels = pixels[:, :2] / pixels[:, 2:3]
|
|
149
|
+
segments = [(pixels[a].tolist(), pixels[b].tolist()) for a, b in edges]
|
|
150
|
+
return {"segments": segments, "center_uv": pixels[4].tolist(), "opening_mm": opening * 1000}
|
package/image_check.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Vision QC for a generated image before it enters a deck.
|
|
3
|
+
|
|
4
|
+
Checks the things a prompt cannot guarantee: does the subject match the brief,
|
|
5
|
+
is there accidental text or a watermark, is the background clean, and which
|
|
6
|
+
side is quiet enough for copy. Returns JSON and exits 2 when the asset fails.
|
|
7
|
+
|
|
8
|
+
python3 image_check.py assets/hero.png --intent "robot arm and amber bottle,
|
|
9
|
+
negative space on the left" --min-score 7
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import base64
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import urllib.request
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
BASE = "https://api.deepseek.com"
|
|
22
|
+
MODEL = "deepseek-v4-flash-vision-exp"
|
|
23
|
+
PROMPT = """You are a strict image QA for presentation assets. Judge the image against the intent.
|
|
24
|
+
Return ONLY a JSON object with this shape:
|
|
25
|
+
{"subject_match": 0-10, "has_text": true/false, "has_watermark": true/false,
|
|
26
|
+
"background_clean": 0-10, "negative_space": "left|right|top|bottom|none",
|
|
27
|
+
"issues": ["short concrete issue"], "score": 0-10}
|
|
28
|
+
Rules: score reflects fitness as a full-bleed slide image; accidental text, logos,
|
|
29
|
+
watermarks, malformed hands or collages are automatic failures; "negative_space"
|
|
30
|
+
is the quietest side for overlaying copy. Intent: %s"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _key() -> str:
|
|
34
|
+
key = os.environ.get("DEEPSEEK_API_KEY", "").strip()
|
|
35
|
+
if key:
|
|
36
|
+
return key
|
|
37
|
+
homes = [os.environ.get("DSH_HOME", "").strip(),
|
|
38
|
+
os.path.expanduser("~/.dsh-linux"), os.path.expanduser("~/.dsh")]
|
|
39
|
+
for home in homes:
|
|
40
|
+
if not home:
|
|
41
|
+
continue
|
|
42
|
+
path = os.path.join(home, ".credentials.yaml")
|
|
43
|
+
if not os.path.isfile(path):
|
|
44
|
+
continue
|
|
45
|
+
try:
|
|
46
|
+
for line in open(path, encoding="utf-8"):
|
|
47
|
+
stripped = line.strip()
|
|
48
|
+
if stripped.startswith("DEEPSEEK_API_KEY:"):
|
|
49
|
+
value = stripped.split(":", 1)[1].strip().strip("'\"")
|
|
50
|
+
if value:
|
|
51
|
+
return value
|
|
52
|
+
except OSError:
|
|
53
|
+
continue
|
|
54
|
+
return ""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _mime(path: Path) -> str:
|
|
58
|
+
return {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
|
59
|
+
".webp": "image/webp"}.get(path.suffix.lower(), "image/png")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _parse(text: str) -> dict:
|
|
63
|
+
start, end = text.find("{"), text.rfind("}")
|
|
64
|
+
if start < 0 or end <= start:
|
|
65
|
+
raise ValueError(f"模型没有返回 JSON:{text[:200]}")
|
|
66
|
+
data = json.loads(text[start:end + 1])
|
|
67
|
+
for field in ("subject_match", "has_text", "has_watermark",
|
|
68
|
+
"background_clean", "negative_space", "issues", "score"):
|
|
69
|
+
if field not in data:
|
|
70
|
+
raise ValueError(f"模型 JSON 缺少 {field}")
|
|
71
|
+
return data
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def check(image: Path, intent: str, min_score: float, timeout: int = 120) -> dict:
|
|
75
|
+
key = _key()
|
|
76
|
+
if not key:
|
|
77
|
+
raise RuntimeError("未找到 DEEPSEEK_API_KEY(env、$DSH_HOME、~/.dsh-linux、~/.dsh)")
|
|
78
|
+
payload = {
|
|
79
|
+
"model": MODEL,
|
|
80
|
+
"messages": [{"role": "user", "content": [
|
|
81
|
+
{"type": "text", "text": PROMPT % intent},
|
|
82
|
+
{"type": "image_url", "image_url": {
|
|
83
|
+
"url": f"data:{_mime(image)};base64," +
|
|
84
|
+
base64.b64encode(image.read_bytes()).decode()}},
|
|
85
|
+
]}],
|
|
86
|
+
"temperature": 0,
|
|
87
|
+
}
|
|
88
|
+
data = json.dumps(payload).encode("utf-8")
|
|
89
|
+
headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"}
|
|
90
|
+
try:
|
|
91
|
+
import requests
|
|
92
|
+
except ImportError:
|
|
93
|
+
requests = None
|
|
94
|
+
if requests is not None:
|
|
95
|
+
proxy = (os.environ.get("PPT_IMAGE_PROXY") or os.environ.get("HTTPS_PROXY")
|
|
96
|
+
or os.environ.get("https_proxy"))
|
|
97
|
+
proxies = {"http": proxy, "https": proxy} if proxy else None
|
|
98
|
+
response = requests.post(BASE + "/chat/completions", data=data, headers=headers,
|
|
99
|
+
timeout=timeout, proxies=proxies)
|
|
100
|
+
if response.status_code >= 400:
|
|
101
|
+
raise RuntimeError(f"视觉接口 {response.status_code}: {response.text[:300]}")
|
|
102
|
+
body = response.json()
|
|
103
|
+
else:
|
|
104
|
+
request = urllib.request.Request(BASE + "/chat/completions", data=data,
|
|
105
|
+
headers=headers, method="POST")
|
|
106
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
107
|
+
body = json.loads(response.read().decode("utf-8"))
|
|
108
|
+
text = body["choices"][0]["message"]["content"]
|
|
109
|
+
report = _parse(text)
|
|
110
|
+
report["pass"] = (float(report["score"]) >= min_score
|
|
111
|
+
and not report["has_text"] and not report["has_watermark"])
|
|
112
|
+
return report
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def main() -> int:
|
|
116
|
+
parser = argparse.ArgumentParser(description=__doc__,
|
|
117
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
118
|
+
parser.add_argument("image", type=Path)
|
|
119
|
+
parser.add_argument("--intent", required=True, help="what the image must show and leave room for")
|
|
120
|
+
parser.add_argument("--min-score", type=float, default=7.0)
|
|
121
|
+
parser.add_argument("--timeout", type=int, default=120)
|
|
122
|
+
parser.add_argument("--json", action="store_true", help="print the raw report only")
|
|
123
|
+
parser.add_argument("--transparent", action="store_true",
|
|
124
|
+
help="asset is a transparent cutout; ignore the flattened white preview")
|
|
125
|
+
args = parser.parse_args()
|
|
126
|
+
intent = args.intent
|
|
127
|
+
if args.transparent:
|
|
128
|
+
intent += (" The asset is a transparent-background cutout; transparency is flattened "
|
|
129
|
+
"to white in this preview, so do not treat the white background as an error.")
|
|
130
|
+
try:
|
|
131
|
+
report = check(args.image, intent, args.min_score, args.timeout)
|
|
132
|
+
except Exception as error:
|
|
133
|
+
print(f"image_check: {error}", file=sys.stderr)
|
|
134
|
+
return 2
|
|
135
|
+
if args.json:
|
|
136
|
+
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
137
|
+
else:
|
|
138
|
+
print(f"score={report['score']} pass={report['pass']} "
|
|
139
|
+
f"subject={report['subject_match']} clean={report['background_clean']} "
|
|
140
|
+
f"text={report['has_text']} watermark={report['has_watermark']} "
|
|
141
|
+
f"quiet={report['negative_space']}")
|
|
142
|
+
for issue in report.get("issues") or []:
|
|
143
|
+
print(f"- {issue}")
|
|
144
|
+
return 0 if report["pass"] else 2
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
if __name__ == "__main__":
|
|
148
|
+
raise SystemExit(main())
|
package/image_fetch.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
图片获取 —— 关键词联网取图,用于 PPT 配图「插入/填充」。
|
|
4
|
+
默认走国内(必应国内图片搜索 cn.bing.com,直连、无需 key、国内站点优先);
|
|
5
|
+
国内找不到 → Pexels(走代理,外网)→ loremflickr/picsum 兜底。
|
|
6
|
+
|
|
7
|
+
代理:读环境变量 PPT_PROXY,默认 http://127.0.0.1:7897(仅用于国外站点 Pexels/loremflickr/picsum)。
|
|
8
|
+
国内站点(cn.bing.com + 中文图床)强制直连,不受 shell 代理影响。
|
|
9
|
+
|
|
10
|
+
用法:
|
|
11
|
+
python3 image_fetch.py <keyword> <out.jpg> [width] [height]
|
|
12
|
+
也可作为模块: from image_fetch import fetch
|
|
13
|
+
"""
|
|
14
|
+
import sys, os, json, re, urllib.request, urllib.parse
|
|
15
|
+
|
|
16
|
+
CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.json')
|
|
17
|
+
PROXY = os.environ.get('PPT_PROXY', 'http://127.0.0.1:7897')
|
|
18
|
+
|
|
19
|
+
# 强制直连(绕过 shell 的 http_proxy,用于国内站点)
|
|
20
|
+
_NO_PROXY_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
21
|
+
# 走代理(用于国外站点 Pexels/loremflickr/picsum)
|
|
22
|
+
_PROXY_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({'http': PROXY, 'https': PROXY}))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _open(url, timeout=25, headers=None, via_proxy=False):
|
|
26
|
+
req = urllib.request.Request(url, headers=headers or {})
|
|
27
|
+
opener = _PROXY_OPENER if via_proxy else _NO_PROXY_OPENER
|
|
28
|
+
return opener.open(req, timeout=timeout)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _pexels_key():
|
|
32
|
+
k = os.environ.get('PEXELS_API_KEY', '').strip()
|
|
33
|
+
if k:
|
|
34
|
+
return k
|
|
35
|
+
if os.path.exists(CONFIG_PATH):
|
|
36
|
+
try:
|
|
37
|
+
return json.load(open(CONFIG_PATH, encoding='utf-8')).get('pexels_key', '').strip()
|
|
38
|
+
except Exception:
|
|
39
|
+
return ''
|
|
40
|
+
return ''
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _has_cjk(s):
|
|
44
|
+
return any('\u4e00' <= c <= '\u9fff' for c in s)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _is_domestic_host(url):
|
|
48
|
+
"""粗略判断图片 URL 是否来自国内站点,用于中文题材优先。"""
|
|
49
|
+
try:
|
|
50
|
+
host = urllib.parse.urlsplit(url).netloc.lower()
|
|
51
|
+
except Exception:
|
|
52
|
+
return False
|
|
53
|
+
if host.endswith(('.cn', '.com.cn', '.net.cn', '.org.cn')):
|
|
54
|
+
return True
|
|
55
|
+
return any(k in host for k in (
|
|
56
|
+
'zhimg.com', 'byteimg.com', 'sinaimg.cn', 'gtimg.cn', 'bjd.com.cn',
|
|
57
|
+
'suning.cn', 'zdmimg.com', 'bbtnews.com.cn', 'sohu.com', '163.com',
|
|
58
|
+
'baidu.com', 'bdimg.com', 'alicdn.com', 'taobao.com', 'jd.com',
|
|
59
|
+
))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def search_pexels(keyword, key=None, per_page=1, orientation='landscape'):
|
|
63
|
+
"""Pexels 语义搜图(外网,走代理)。返回最匹配照片 dict,无结果返回 None。"""
|
|
64
|
+
key = key or _pexels_key()
|
|
65
|
+
if not key:
|
|
66
|
+
return None
|
|
67
|
+
q = urllib.parse.quote(keyword)
|
|
68
|
+
url = f"https://api.pexels.com/v1/search?query={q}&per_page={per_page}&orientation={orientation}"
|
|
69
|
+
with _open(url, timeout=25, headers={'Authorization': key, 'User-Agent': 'Mozilla/5.0'}, via_proxy=True) as r:
|
|
70
|
+
data = json.loads(r.read().decode('utf-8'))
|
|
71
|
+
photos = data.get('photos', [])
|
|
72
|
+
return photos[0] if photos else None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def search_bing_cn(keyword, count=20):
|
|
76
|
+
"""必应国内图片搜索(直连、无需 key)。返回完整图 URL 列表,国内站点优先。"""
|
|
77
|
+
q = urllib.parse.quote(keyword)
|
|
78
|
+
url = f"https://cn.bing.com/images/async?q={q}&first=0&count={count}&mmasync=1"
|
|
79
|
+
with _open(url, timeout=25, headers={
|
|
80
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
81
|
+
'Referer': 'https://cn.bing.com/images/search',
|
|
82
|
+
}, via_proxy=False) as r:
|
|
83
|
+
t = r.read().decode('utf-8', 'ignore')
|
|
84
|
+
urls = re.findall(r'murl":"(.*?)"', t)
|
|
85
|
+
urls.sort(key=lambda u: 0 if _is_domestic_host(u) else 1)
|
|
86
|
+
return urls
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _save(data, out_path):
|
|
90
|
+
os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
|
|
91
|
+
with open(out_path, 'wb') as f:
|
|
92
|
+
f.write(data)
|
|
93
|
+
return out_path
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _download(url, out_path, timeout=30, via_proxy=False):
|
|
97
|
+
with _open(url, timeout=timeout, headers={'User-Agent': 'Mozilla/5.0'}, via_proxy=via_proxy) as r:
|
|
98
|
+
data = r.read()
|
|
99
|
+
if len(data) < 1000:
|
|
100
|
+
raise RuntimeError('下载内容过小')
|
|
101
|
+
return _save(data, out_path)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def fetch_bing_cn(keyword, out_path, count=20):
|
|
105
|
+
"""从必应国内图片搜索下载第一张能成功下载的图(国内站点优先、直连)。返回 (path, src_url)。"""
|
|
106
|
+
urls = search_bing_cn(keyword, count)
|
|
107
|
+
for u in urls:
|
|
108
|
+
if u.startswith('//'):
|
|
109
|
+
u = 'https:' + u
|
|
110
|
+
try:
|
|
111
|
+
with _open(u, timeout=20, headers={
|
|
112
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
|
|
113
|
+
'Referer': 'https://cn.bing.com/images/search',
|
|
114
|
+
}, via_proxy=False) as r:
|
|
115
|
+
data = r.read()
|
|
116
|
+
if len(data) < 1000:
|
|
117
|
+
continue
|
|
118
|
+
_save(data, out_path)
|
|
119
|
+
return out_path, u
|
|
120
|
+
except Exception:
|
|
121
|
+
continue
|
|
122
|
+
raise RuntimeError(f"必应图片搜索下载失败(关键词='{keyword}')")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def search_taobao(keyword, count=15):
|
|
126
|
+
"""淘宝商品图搜索(直连):返回 alicdn 商品图 URL 列表(商品图通常是白底实拍,适合商品题材)。"""
|
|
127
|
+
q = urllib.parse.quote(keyword)
|
|
128
|
+
url = f"https://s.taobao.com/search?q={q}"
|
|
129
|
+
with _open(url, timeout=25, headers={
|
|
130
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
131
|
+
'Referer': 'https://www.taobao.com/',
|
|
132
|
+
}, via_proxy=False) as r:
|
|
133
|
+
t = r.read().decode('utf-8', 'ignore')
|
|
134
|
+
urls = re.findall(r'//img\.alicdn\.com/[^"\s\\]+', t)
|
|
135
|
+
seen = []
|
|
136
|
+
for u in urls:
|
|
137
|
+
u = 'https:' + u
|
|
138
|
+
if u not in seen:
|
|
139
|
+
seen.append(u)
|
|
140
|
+
return seen[:count]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def fetch_taobao(keyword, out_path, count=15):
|
|
144
|
+
"""从淘宝搜索下载第一张能成功下载的商品图。返回 (path, src_url)。"""
|
|
145
|
+
urls = search_taobao(keyword, count)
|
|
146
|
+
for u in urls:
|
|
147
|
+
try:
|
|
148
|
+
with _open(u, timeout=20, headers={
|
|
149
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
|
|
150
|
+
'Referer': 'https://s.taobao.com/',
|
|
151
|
+
}, via_proxy=False) as r:
|
|
152
|
+
data = r.read()
|
|
153
|
+
if len(data) < 1000:
|
|
154
|
+
continue
|
|
155
|
+
_save(data, out_path)
|
|
156
|
+
return out_path, u
|
|
157
|
+
except Exception:
|
|
158
|
+
continue
|
|
159
|
+
raise RuntimeError(f"淘宝商品图搜索下载失败(关键词='{keyword}')")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
_TRANSLATE_DICT = {
|
|
163
|
+
'电动自行车': 'electric bicycle', '电瓶车': 'electric scooter moped',
|
|
164
|
+
'电动车': 'electric scooter', '电动摩托车': 'electric motorcycle',
|
|
165
|
+
'充电桩': 'ev charging station', '充电站': 'charging station',
|
|
166
|
+
'外卖': 'food delivery rider', '头盔': 'helmet', '电池': 'battery',
|
|
167
|
+
'骑行': 'cycling', '城市': 'city street', '交通': 'traffic',
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _deepseek_key():
|
|
172
|
+
k = os.environ.get('DEEPSEEK_API_KEY', '').strip()
|
|
173
|
+
if k:
|
|
174
|
+
return k
|
|
175
|
+
cred = os.path.expanduser('~/.dsh/.credentials.yaml')
|
|
176
|
+
if os.path.exists(cred):
|
|
177
|
+
try:
|
|
178
|
+
for line in open(cred, encoding='utf-8'):
|
|
179
|
+
if line.startswith('DEEPSEEK_API_KEY'):
|
|
180
|
+
return line.split(':', 1)[1].strip()
|
|
181
|
+
except Exception:
|
|
182
|
+
pass
|
|
183
|
+
return ''
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _translate_en(keyword):
|
|
187
|
+
"""中文关键词 → 英文(先查内置词表,再走 DeepSeek 翻译),供 Pexels 兜底用。"""
|
|
188
|
+
if not _has_cjk(keyword):
|
|
189
|
+
return keyword
|
|
190
|
+
if keyword in _TRANSLATE_DICT:
|
|
191
|
+
return _TRANSLATE_DICT[keyword]
|
|
192
|
+
dk = _deepseek_key()
|
|
193
|
+
if not dk:
|
|
194
|
+
return keyword
|
|
195
|
+
try:
|
|
196
|
+
payload = {
|
|
197
|
+
'model': 'deepseek-v4-flash',
|
|
198
|
+
'messages': [{'role': 'user', 'content': f'把下面中文关键词翻译成英文,只输出英文(3 个单词以内,不要解释):{keyword}'}],
|
|
199
|
+
}
|
|
200
|
+
with _open('https://api.deepseek.com/chat/completions', timeout=20,
|
|
201
|
+
headers={'Authorization': 'Bearer ' + dk, 'Content-Type': 'application/json'},
|
|
202
|
+
via_proxy=True) as r:
|
|
203
|
+
d = json.loads(r.read().decode('utf-8'))
|
|
204
|
+
en = d['choices'][0]['message']['content'].strip().strip('"')
|
|
205
|
+
return en if en else keyword
|
|
206
|
+
except Exception:
|
|
207
|
+
return keyword
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def fetch(keyword, out_path, w=1600, h=900, key=None):
|
|
211
|
+
"""下载配图,返回 (path, attribution)。默认走国内(必应国内),找不到再走 Pexels(英文关键词)。"""
|
|
212
|
+
key = key or _pexels_key()
|
|
213
|
+
# 1) 默认:必应国内搜索(直连、国内站点优先,国内题材不违和)
|
|
214
|
+
try:
|
|
215
|
+
path, _ = fetch_bing_cn(keyword, out_path)
|
|
216
|
+
return path, ""
|
|
217
|
+
except Exception:
|
|
218
|
+
pass
|
|
219
|
+
# 2) 淘宝商品图(商品题材更贴切:白底实拍;图片偏小,作补充源)
|
|
220
|
+
try:
|
|
221
|
+
path, _ = fetch_taobao(keyword, out_path)
|
|
222
|
+
return path, ""
|
|
223
|
+
except Exception:
|
|
224
|
+
pass
|
|
225
|
+
# 3) 国内都找不到 → Pexels(外网、走代理;中文关键词先翻成英文,命中更准)
|
|
226
|
+
try:
|
|
227
|
+
en = _translate_en(keyword)
|
|
228
|
+
ph = search_pexels(en, key=key)
|
|
229
|
+
if ph:
|
|
230
|
+
src = ph['src'].get('large2x') or ph['src'].get('large') or ph['src'].get('original')
|
|
231
|
+
_download(src, out_path, via_proxy=True)
|
|
232
|
+
return out_path, ""
|
|
233
|
+
except Exception:
|
|
234
|
+
pass
|
|
235
|
+
# 3) 都不行就报错(不再走 loremflickr/picsum:中文关键词只会拿到随机图)
|
|
236
|
+
raise RuntimeError(f"图片获取失败(关键词='{keyword}'),请换关键词或人工指定图片")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
if __name__ == '__main__':
|
|
240
|
+
if len(sys.argv) < 3:
|
|
241
|
+
print("用法: python3 image_fetch.py <keyword> <out.jpg> [width] [height]")
|
|
242
|
+
sys.exit(1)
|
|
243
|
+
kw = sys.argv[1]; out = sys.argv[2]
|
|
244
|
+
w = int(sys.argv[3]) if len(sys.argv) > 3 else 1600
|
|
245
|
+
h = int(sys.argv[4]) if len(sys.argv) > 4 else 900
|
|
246
|
+
try:
|
|
247
|
+
path, attr = fetch(kw, out, w, h)
|
|
248
|
+
print(f"已保存: {path}")
|
|
249
|
+
if attr:
|
|
250
|
+
print(f"署名: {attr}")
|
|
251
|
+
except Exception as e:
|
|
252
|
+
print(f"失败: {e}")
|
|
253
|
+
sys.exit(2)
|
package/ingest.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
文件摄取工具 —— 让 PPT 助手「接受」现有 PPT / PDF / 图片文件:
|
|
4
|
+
读取 .pptx / .pdf 的内容与结构,输出可读文本供助手分析、重排、转写。
|
|
5
|
+
扫描版 PDF(无文字层)自动走 OCR(pdftoppm + tesseract)。
|
|
6
|
+
|
|
7
|
+
用法:
|
|
8
|
+
python3 ingest.py <file.pptx|file.pdf|file.png|file.jpg>
|
|
9
|
+
"""
|
|
10
|
+
import sys, os, subprocess, glob, tempfile
|
|
11
|
+
|
|
12
|
+
def ingest_pptx(path):
|
|
13
|
+
from pptx import Presentation
|
|
14
|
+
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
|
15
|
+
prs = Presentation(path)
|
|
16
|
+
out = []
|
|
17
|
+
out.append(f"# PPT 摄取: {os.path.basename(path)}")
|
|
18
|
+
out.append(f"页数: {len(prs.slides)} 尺寸: {prs.slide_width/914400:.2f}x{prs.slide_height/914400:.2f} in")
|
|
19
|
+
for i, s in enumerate(prs.slides, 1):
|
|
20
|
+
layout = s.slide_layout.name if s.slide_layout else '?'
|
|
21
|
+
out.append(f"\n--- 第{i}页 (layout: {layout}) ---")
|
|
22
|
+
for sh in s.shapes:
|
|
23
|
+
if sh.has_text_frame and sh.text_frame.text.strip():
|
|
24
|
+
out.append(f" [文本] {sh.text_frame.text.strip()}")
|
|
25
|
+
elif sh.has_table:
|
|
26
|
+
tbl = sh.table
|
|
27
|
+
out.append(f" [表格] {len(tbl.rows)}行 x {len(tbl.columns)}列")
|
|
28
|
+
for r in tbl.rows:
|
|
29
|
+
out.append(" | " + " | ".join(c.text.strip() for c in r.cells))
|
|
30
|
+
elif hasattr(sh, 'has_chart') and sh.has_chart:
|
|
31
|
+
out.append(f" [图表] {sh.name}")
|
|
32
|
+
elif sh.shape_type == MSO_SHAPE_TYPE.PICTURE:
|
|
33
|
+
out.append(f" [图片] {sh.name}")
|
|
34
|
+
if s.has_notes_slide:
|
|
35
|
+
note = s.notes_slide.notes_text_frame.text.strip()
|
|
36
|
+
if note:
|
|
37
|
+
out.append(f" [备注] {note}")
|
|
38
|
+
return "\n".join(out)
|
|
39
|
+
|
|
40
|
+
def _tesseract_ocr(image_path):
|
|
41
|
+
"""对单张图片 OCR(中英混合)。"""
|
|
42
|
+
r = subprocess.run(
|
|
43
|
+
["tesseract", image_path, "stdout", "-l", "chi_sim+eng", "--psm", "3"],
|
|
44
|
+
capture_output=True, text=True)
|
|
45
|
+
return (r.stdout or "").strip()
|
|
46
|
+
|
|
47
|
+
def ingest_image(path):
|
|
48
|
+
txt = _tesseract_ocr(path)
|
|
49
|
+
return f"# 图片 OCR: {os.path.basename(path)}\n{txt}"
|
|
50
|
+
|
|
51
|
+
def _ocr_scanned_pdf(path):
|
|
52
|
+
"""扫描版 PDF:逐页渲染为 PNG 再 OCR。"""
|
|
53
|
+
out = [f"# PDF 摄取(扫描版, 经 OCR): {os.path.basename(path)}"]
|
|
54
|
+
with tempfile.TemporaryDirectory() as td:
|
|
55
|
+
subprocess.run(
|
|
56
|
+
["pdftoppm", "-png", "-r", "200", path, os.path.join(td, "page")],
|
|
57
|
+
check=True, capture_output=True)
|
|
58
|
+
imgs = sorted(glob.glob(os.path.join(td, "page-*.png")))
|
|
59
|
+
out.append(f"总页数: {len(imgs)}")
|
|
60
|
+
for i, img in enumerate(imgs, 1):
|
|
61
|
+
txt = _tesseract_ocr(img)
|
|
62
|
+
out.append(f"\n--- 第{i}页(OCR) ---\n{txt}")
|
|
63
|
+
return "\n".join(out)
|
|
64
|
+
|
|
65
|
+
def ingest_pdf(path):
|
|
66
|
+
from pypdf import PdfReader
|
|
67
|
+
r = PdfReader(path)
|
|
68
|
+
pages = []
|
|
69
|
+
total_text = ""
|
|
70
|
+
for i, page in enumerate(r.pages, 1):
|
|
71
|
+
t = page.extract_text() or ""
|
|
72
|
+
total_text += t
|
|
73
|
+
pages.append(f"\n--- 第{i}页 ---\n{t}")
|
|
74
|
+
if total_text.strip():
|
|
75
|
+
return f"# PDF 摄取: {os.path.basename(path)}\n总页数: {len(pages)}" + "".join(pages)
|
|
76
|
+
# 无文字层 → 扫描版,走 OCR
|
|
77
|
+
return _ocr_scanned_pdf(path)
|
|
78
|
+
|
|
79
|
+
if __name__ == '__main__':
|
|
80
|
+
if len(sys.argv) < 2:
|
|
81
|
+
print("用法: python3 ingest.py <file.pptx|file.pdf|file.png|file.jpg>")
|
|
82
|
+
sys.exit(1)
|
|
83
|
+
path = sys.argv[1]
|
|
84
|
+
ext = os.path.splitext(path)[1].lower()
|
|
85
|
+
if ext == '.pptx':
|
|
86
|
+
print(ingest_pptx(path))
|
|
87
|
+
elif ext == '.pdf':
|
|
88
|
+
print(ingest_pdf(path))
|
|
89
|
+
elif ext in ('.png', '.jpg', '.jpeg', '.tif', '.tiff', '.bmp'):
|
|
90
|
+
print(ingest_image(path))
|
|
91
|
+
else:
|
|
92
|
+
print(f"不支持的类型: {ext}(仅支持 .pptx / .pdf / 图片)")
|
|
93
|
+
sys.exit(2)
|