sophhub 0.1.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/bin/sophhub.js +21 -0
- package/package.json +32 -0
- package/skills/VERSIONS.md +27 -0
- package/skills/builtin/clawhub/SKILL.md +77 -0
- package/skills/builtin/flight-booking/SKILL.md +288 -0
- package/skills/builtin/flight-booking/scripts/flight_booking.py +1232 -0
- package/skills/builtin/inventory-management/SKILL.md +241 -0
- package/skills/builtin/inventory-management/scripts/inventory.py +1844 -0
- package/skills/builtin/schedule-reminder/SKILL.md +619 -0
- package/skills/builtin/schedule-reminder/schedule_template.md +68 -0
- package/skills/builtin/schedule-reminder/scripts/append_event.py +204 -0
- package/skills/builtin/schedule-reminder/scripts/create_reminders.sh +163 -0
- package/skills/builtin/schedule-reminder/scripts/daily_activate.sh +175 -0
- package/skills/builtin/schedule-reminder/scripts/parse_schedule.py +704 -0
- package/skills/builtin/schedule-reminder/scripts/setup.sh +242 -0
- package/skills/builtin/schedule-reminder//347/224/250/346/210/267/346/214/207/345/215/227.md +311 -0
- package/skills/builtin/skill-creator/SKILL.md +370 -0
- package/skills/builtin/skill-creator/license.txt +202 -0
- package/skills/builtin/skill-creator/scripts/init_skill.py +378 -0
- package/skills/builtin/skill-creator/scripts/package_skill.py +111 -0
- package/skills/builtin/skill-creator/scripts/quick_validate.py +101 -0
- package/skills/builtin/sophnet-customer-management/SKILL.md +271 -0
- package/skills/builtin/sophnet-customer-management/pyproject.toml +15 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/__init__.py +2 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/__main__.py +5 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/cli.py +67 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/__init__.py +2 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/customer.py +60 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/export_file.py +18 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/import_file.py +15 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/reminder.py +26 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/schema.py +28 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/config.py +54 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/__init__.py +2 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/exporter.py +85 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/models.py +84 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/normalizer.py +144 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/parser.py +241 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/query.py +109 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/reminder.py +121 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/repository.py +397 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/schema.py +106 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/service.py +565 -0
- package/skills/builtin/sophnet-customer-management/uv.lock +48 -0
- package/skills/builtin/sophnet-customized-marketing/SKILL.md +144 -0
- package/skills/builtin/sophnet-customized-marketing/playbooks/campaign-planning.md +187 -0
- package/skills/builtin/sophnet-customized-marketing/playbooks/content-generation.md +124 -0
- package/skills/builtin/sophnet-customized-marketing/playbooks/marketing-calendar.md +59 -0
- package/skills/builtin/sophnet-customized-marketing/playbooks/multi-channel-bundle.md +94 -0
- package/skills/builtin/sophnet-customized-marketing/playbooks/poster-generation.md +182 -0
- package/skills/builtin/sophnet-customized-marketing/playbooks/style-profile-workflow.md +103 -0
- package/skills/builtin/sophnet-customized-marketing/pyproject.toml +9 -0
- package/skills/builtin/sophnet-customized-marketing/references/campaign-mechanics.md +168 -0
- package/skills/builtin/sophnet-customized-marketing/references/content-safety.md +26 -0
- package/skills/builtin/sophnet-customized-marketing/references/marketing-date-checklist.md +99 -0
- package/skills/builtin/sophnet-customized-marketing/references/platform-writing-guidelines.md +88 -0
- package/skills/builtin/sophnet-customized-marketing/references/quality-checklist.md +44 -0
- package/skills/builtin/sophnet-customized-marketing/scripts/generate_poster.py +585 -0
- package/skills/builtin/sophnet-customized-marketing/scripts/style_profile.py +215 -0
- package/skills/builtin/sophnet-face-search/SKILL.md +115 -0
- package/skills/builtin/sophnet-face-search/pyproject.toml +11 -0
- package/skills/builtin/sophnet-face-search/scripts/face_search.py +336 -0
- package/skills/builtin/sophnet-face-search/uv.lock +508 -0
- package/skills/builtin/sophnet-image-edit/SKILL.md +140 -0
- package/skills/builtin/sophnet-image-edit/pyproject.toml +9 -0
- package/skills/builtin/sophnet-image-edit/scripts/edit_and_preview.sh +68 -0
- package/skills/builtin/sophnet-image-edit/scripts/edit_image.py +279 -0
- package/skills/builtin/sophnet-image-edit/uv.lock +234 -0
- package/skills/builtin/sophnet-image-generate/SKILL.md +62 -0
- package/skills/builtin/sophnet-image-generate/pyproject.toml +9 -0
- package/skills/builtin/sophnet-image-generate/scripts/generate_image.py +156 -0
- package/skills/builtin/sophnet-image-generate/uv.lock +234 -0
- package/skills/builtin/sophnet-image-ocr/SKILL.md +167 -0
- package/skills/builtin/sophnet-image-ocr/pyproject.toml +13 -0
- package/skills/builtin/sophnet-image-ocr/scripts/ocr.py +226 -0
- package/skills/builtin/sophnet-image-ocr/uv.lock +234 -0
- package/skills/builtin/sophnet-infinite-talk/SKILL.md +140 -0
- package/skills/builtin/sophnet-infinite-talk/pyproject.toml +9 -0
- package/skills/builtin/sophnet-infinite-talk/scripts/gen.py +172 -0
- package/skills/builtin/sophnet-oss/SKILL.md +109 -0
- package/skills/builtin/sophnet-oss/pyproject.toml +8 -0
- package/skills/builtin/sophnet-oss/scripts/upload_file.py +43 -0
- package/skills/builtin/sophnet-qa-install/SKILL.md +210 -0
- package/skills/builtin/sophnet-qa-install/pyproject.toml +6 -0
- package/skills/builtin/sophnet-qa-install/scripts/backup_md.py +35 -0
- package/skills/builtin/sophnet-qa-install/scripts/check_installed.py +143 -0
- package/skills/builtin/sophnet-qa-install/scripts/update_config.py +142 -0
- package/skills/builtin/sophnet-qa-install/scripts/update_md.py +73 -0
- package/skills/builtin/sophnet-training-install/SKILL.md +211 -0
- package/skills/builtin/sophnet-training-install/pyproject.toml +6 -0
- package/skills/builtin/sophnet-training-install/scripts/backup_md.py +35 -0
- package/skills/builtin/sophnet-training-install/scripts/check_installed.py +144 -0
- package/skills/builtin/sophnet-training-install/scripts/update_config.py +142 -0
- package/skills/builtin/sophnet-training-install/scripts/update_md.py +73 -0
- package/skills/builtin/sophnet-tts/SKILL.md +79 -0
- package/skills/builtin/sophnet-tts/pyproject.toml +9 -0
- package/skills/builtin/sophnet-tts/scripts/gen_tts.py +130 -0
- package/skills/builtin/sophnet-video-generate/SKILL.md +116 -0
- package/skills/builtin/sophnet-video-generate/scripts/gen_video.py +304 -0
- package/skills/builtin/video-understand/SKILL.md +79 -0
- package/skills/builtin/video-understand/scripts/video_understand.py +204 -0
- package/skills/builtin/weather/SKILL.md +112 -0
- package/skills/builtin/web-scraper/SKILL.md +101 -0
- package/skills/builtin/web-scraper/scripts/scrape.py +270 -0
- package/skills/builtin/website-builder/SKILL.md +266 -0
- package/skills/builtin/website-builder/scripts/deploy_site.sh +46 -0
- package/skills/store/didi-ride/SKILL.md +309 -0
- package/skills/store/didi-ride/_meta.json +6 -0
- package/skills/store/didi-ride/assets/PREFERENCE.md +58 -0
- package/skills/store/didi-ride/package.json +15 -0
- package/skills/store/didi-ride/references/api_references.md +171 -0
- package/skills/store/didi-ride/references/error_handling.md +68 -0
- package/skills/store/didi-ride/references/setup.md +73 -0
- package/skills/store/didi-ride/references/workflow.md +150 -0
- package/skills/store/flyai/SKILL.md +119 -0
- package/skills/store/flyai/references/fliggy-fast-search.md +53 -0
- package/skills/store/flyai/references/search-flight.md +89 -0
- package/skills/store/flyai/references/search-hotels.md +57 -0
- package/skills/store/flyai/references/search-poi.md +49 -0
- package/src/commands/download.js +103 -0
- package/src/commands/list.js +67 -0
- package/src/utils/config.js +24 -0
- package/src/utils/gitlab.js +67 -0
- package/src/utils/paths.js +19 -0
- package/src/utils/versions.js +38 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
User marketing style profile manager.
|
|
4
|
+
|
|
5
|
+
Reads, writes, and updates a Markdown-based style profile that captures the
|
|
6
|
+
user's personal expression habits, brand tone, and preferences. The profile
|
|
7
|
+
is stored as `user-style-profile.md` in a configurable directory.
|
|
8
|
+
|
|
9
|
+
This is the only script in the skill that performs data persistence.
|
|
10
|
+
It does NOT modify the user's USER.md file.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
python style_profile.py read [--profile-dir DIR] [--format text|json]
|
|
14
|
+
python style_profile.py write --content "..." [--profile-dir DIR]
|
|
15
|
+
python style_profile.py update --key "表达风格" --value "热情外放" [--profile-dir DIR]
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Constants
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
PROFILE_FILENAME = "user-style-profile.md"
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Profile parsing & serialization
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_sections(content: str) -> list[tuple[str, str]]:
|
|
37
|
+
"""Parse markdown content into a list of (heading, body) tuples.
|
|
38
|
+
|
|
39
|
+
Sections are delimited by `## heading` lines. Content before the first
|
|
40
|
+
`## ` is treated as the preamble (key="").
|
|
41
|
+
"""
|
|
42
|
+
sections: list[tuple[str, str]] = []
|
|
43
|
+
current_key = ""
|
|
44
|
+
current_lines: list[str] = []
|
|
45
|
+
|
|
46
|
+
for line in content.splitlines():
|
|
47
|
+
if line.startswith("## "):
|
|
48
|
+
sections.append((current_key, "\n".join(current_lines).strip()))
|
|
49
|
+
current_key = line[3:].strip()
|
|
50
|
+
current_lines = []
|
|
51
|
+
else:
|
|
52
|
+
current_lines.append(line)
|
|
53
|
+
|
|
54
|
+
sections.append((current_key, "\n".join(current_lines).strip()))
|
|
55
|
+
return sections
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def serialize_sections(sections: list[tuple[str, str]]) -> str:
|
|
59
|
+
"""Serialize (heading, body) tuples back to markdown."""
|
|
60
|
+
parts: list[str] = []
|
|
61
|
+
for key, body in sections:
|
|
62
|
+
if key:
|
|
63
|
+
parts.append(f"## {key}")
|
|
64
|
+
if body:
|
|
65
|
+
parts.append(body)
|
|
66
|
+
parts.append("")
|
|
67
|
+
return "\n".join(parts).strip() + "\n"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def update_section(content: str, key: str, value: str) -> str:
|
|
71
|
+
"""Update or append a section in the markdown content."""
|
|
72
|
+
sections = parse_sections(content)
|
|
73
|
+
found = False
|
|
74
|
+
for i, (k, _) in enumerate(sections):
|
|
75
|
+
if k == key:
|
|
76
|
+
sections[i] = (k, value)
|
|
77
|
+
found = True
|
|
78
|
+
break
|
|
79
|
+
if not found:
|
|
80
|
+
sections.append((key, value))
|
|
81
|
+
return serialize_sections(sections)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
# Path resolution
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def resolve_profile_path(profile_dir: str) -> Path:
|
|
90
|
+
directory = Path(profile_dir).expanduser()
|
|
91
|
+
return directory / PROFILE_FILENAME
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# Core operations
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def cmd_read(profile_dir: str | None, fmt: str) -> None:
|
|
100
|
+
path = resolve_profile_path(profile_dir)
|
|
101
|
+
|
|
102
|
+
if not path.exists():
|
|
103
|
+
if fmt == "json":
|
|
104
|
+
print(json.dumps({"STATUS": "not_found", "PROFILE_PATH": str(path)},
|
|
105
|
+
ensure_ascii=False, indent=2))
|
|
106
|
+
else:
|
|
107
|
+
print(f"STATUS=not_found")
|
|
108
|
+
print(f"PROFILE_PATH={path}")
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
content = path.read_text(encoding="utf-8")
|
|
112
|
+
|
|
113
|
+
if fmt == "json":
|
|
114
|
+
sections = parse_sections(content)
|
|
115
|
+
section_dict = {k: v for k, v in sections if k}
|
|
116
|
+
print(json.dumps({
|
|
117
|
+
"STATUS": "ok",
|
|
118
|
+
"PROFILE_PATH": str(path),
|
|
119
|
+
"sections": section_dict,
|
|
120
|
+
"raw": content,
|
|
121
|
+
}, ensure_ascii=False, indent=2))
|
|
122
|
+
else:
|
|
123
|
+
print(f"STATUS=ok")
|
|
124
|
+
print(f"PROFILE_PATH={path}")
|
|
125
|
+
print("---")
|
|
126
|
+
print(content, end="")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def cmd_write(content: str, profile_dir: str | None) -> None:
|
|
130
|
+
path = resolve_profile_path(profile_dir)
|
|
131
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
path.write_text(content, encoding="utf-8")
|
|
133
|
+
print(f"STATUS=ok")
|
|
134
|
+
print(f"PROFILE_PATH={path}")
|
|
135
|
+
print(f"MESSAGE=Profile written ({len(content)} chars)")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def cmd_update(key: str, value: str, profile_dir: str | None) -> None:
|
|
139
|
+
path = resolve_profile_path(profile_dir)
|
|
140
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
|
|
142
|
+
existed = path.exists()
|
|
143
|
+
if existed:
|
|
144
|
+
old_content = path.read_text(encoding="utf-8")
|
|
145
|
+
else:
|
|
146
|
+
old_content = "# 用户营销风格档案\n\n"
|
|
147
|
+
|
|
148
|
+
new_content = update_section(old_content, key, value)
|
|
149
|
+
path.write_text(new_content, encoding="utf-8")
|
|
150
|
+
|
|
151
|
+
action = "updated" if existed else "created"
|
|
152
|
+
print(f"STATUS=ok")
|
|
153
|
+
print(f"PROFILE_PATH={path}")
|
|
154
|
+
print(f"MESSAGE=Section '{key}' {action}")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
# CLI
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def main(argv: list[str] | None = None) -> None:
|
|
163
|
+
parser = argparse.ArgumentParser(
|
|
164
|
+
description="Read, write, or update the user's marketing style profile.",
|
|
165
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
166
|
+
epilog=(
|
|
167
|
+
"Subcommands:\n"
|
|
168
|
+
" read Read the current style profile\n"
|
|
169
|
+
" write Write (overwrite) the entire style profile\n"
|
|
170
|
+
" update Update a single section by key\n\n"
|
|
171
|
+
"Examples:\n"
|
|
172
|
+
' python style_profile.py read --profile-dir ~/.openclaw/workspace/sophnet-customized-marketing\n'
|
|
173
|
+
' python style_profile.py read --profile-dir /path/to/dir --format json\n'
|
|
174
|
+
' python style_profile.py write --profile-dir /path/to/dir --content "# 用户营销风格档案\\n\\n## 表达风格\\n简洁专业"\n'
|
|
175
|
+
' python style_profile.py update --profile-dir /path/to/dir --key "表达风格" --value "热情活泼,喜欢用 emoji"'
|
|
176
|
+
),
|
|
177
|
+
)
|
|
178
|
+
sub = parser.add_subparsers(dest="command", help="Operation to perform")
|
|
179
|
+
sub.required = True
|
|
180
|
+
|
|
181
|
+
# read
|
|
182
|
+
p_read = sub.add_parser("read", help="Read the current style profile")
|
|
183
|
+
p_read.add_argument("--profile-dir", required=True,
|
|
184
|
+
help="Directory to store/read profile (e.g. ~/.openclaw/workspace/sophnet-customized-marketing)")
|
|
185
|
+
p_read.add_argument("--format", default="text", choices=["text", "json"],
|
|
186
|
+
help="Output format (default: text)")
|
|
187
|
+
|
|
188
|
+
# write
|
|
189
|
+
p_write = sub.add_parser("write", help="Write (overwrite) the entire style profile")
|
|
190
|
+
p_write.add_argument("--content", required=True,
|
|
191
|
+
help="Markdown content to write")
|
|
192
|
+
p_write.add_argument("--profile-dir", required=True,
|
|
193
|
+
help="Directory to store/read profile (e.g. ~/.openclaw/workspace/sophnet-customized-marketing)")
|
|
194
|
+
|
|
195
|
+
# update
|
|
196
|
+
p_update = sub.add_parser("update", help="Update a single section by key")
|
|
197
|
+
p_update.add_argument("--key", required=True,
|
|
198
|
+
help="Section heading to update (e.g. '表达风格')")
|
|
199
|
+
p_update.add_argument("--value", required=True,
|
|
200
|
+
help="New content for the section")
|
|
201
|
+
p_update.add_argument("--profile-dir", required=True,
|
|
202
|
+
help="Directory to store/read profile (e.g. ~/.openclaw/workspace/sophnet-customized-marketing)")
|
|
203
|
+
|
|
204
|
+
args = parser.parse_args(argv)
|
|
205
|
+
|
|
206
|
+
if args.command == "read":
|
|
207
|
+
cmd_read(args.profile_dir, args.format)
|
|
208
|
+
elif args.command == "write":
|
|
209
|
+
cmd_write(args.content, args.profile_dir)
|
|
210
|
+
elif args.command == "update":
|
|
211
|
+
cmd_update(args.key, args.value, args.profile_dir)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
if __name__ == "__main__":
|
|
215
|
+
main()
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sophnet-face-search
|
|
3
|
+
description: Search for similar faces in image directories using face detection and embedding comparison. Use when users want to find photos containing a specific person, search for similar faces across image collections, or identify matching faces in photo libraries. Supports automatic path extraction from logs (patterns like "Resolved relative path"), configurable similarity thresholds, and visual preview of query and result faces.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Face Search
|
|
7
|
+
|
|
8
|
+
Search for similar faces in image directories using Sophnet's face detection API.
|
|
9
|
+
|
|
10
|
+
## Processing Mode
|
|
11
|
+
|
|
12
|
+
**STRICT SERIAL PROCESSING ONLY** - This skill must execute in a single, sequential operation:
|
|
13
|
+
|
|
14
|
+
- **NO sub-tasks** - Never spawn background sessions or use sessions_spawn
|
|
15
|
+
- **NO task splitting** - Process all images in one continuous run
|
|
16
|
+
- **NO parallel execution** - All face detection and comparison must be done serially
|
|
17
|
+
- Complete the entire search workflow before returning results to the user
|
|
18
|
+
|
|
19
|
+
## Prerequisites
|
|
20
|
+
|
|
21
|
+
- Python packages: opencv-python-headless, numpy, requests, sophnet-tools
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
When a user wants to search for faces:
|
|
26
|
+
|
|
27
|
+
1. **Extract image path** from user input or recent logs (look for patterns like `Resolved relative path: "media/inbound/images/xxx.jpg"`)
|
|
28
|
+
2. **Run the search** using uv from /tmp to avoid workspace pyproject.toml conflicts:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
uv run --with opencv-python-headless --with numpy --with requests --with sophnet-tools \
|
|
32
|
+
python {baseDir}/scripts/face_search.py \
|
|
33
|
+
--image_path /absolute/path/to/query.jpg \
|
|
34
|
+
--search_image_path /absolute/path/to/search/folder \
|
|
35
|
+
--threshold 0.4
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Parameters
|
|
39
|
+
|
|
40
|
+
- `--image_path`: Query image containing the face to search for (required)
|
|
41
|
+
- `--search_image_path`: Directory to search for similar faces (required)
|
|
42
|
+
- `--det-thr`: Face detection threshold (default: 0.5)
|
|
43
|
+
- `--threshold`: Similarity threshold for matching (default: 0.4, range: 0.0-1.0)
|
|
44
|
+
|
|
45
|
+
## Output
|
|
46
|
+
|
|
47
|
+
The script outputs:
|
|
48
|
+
|
|
49
|
+
1. Query face preview with bounding box: `MEDIA:/path/to/query_face.jpg`
|
|
50
|
+
2. List of matching images with similarity scores
|
|
51
|
+
3. Each result shows: `MEDIA:/absolute/path/to/result.jpg`
|
|
52
|
+
|
|
53
|
+
**After the search completes, format the output as follows:**
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
查询人脸预览:
|
|
57
|
+
[Use read tool to display the query face (*_face.jpg)]
|
|
58
|
+
|
|
59
|
+
搜索到 N 个相似人脸:
|
|
60
|
+
[Use read tool to display each matching result image]
|
|
61
|
+
|
|
62
|
+
搜索到的图片列表:
|
|
63
|
+
/absolute/path/to/result1.jpg
|
|
64
|
+
/absolute/path/to/result2.png
|
|
65
|
+
/absolute/path/to/result3.jpg
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**Important:**
|
|
69
|
+
|
|
70
|
+
- Always show absolute paths in the final list
|
|
71
|
+
- Use `read` tool to visually display both query face and all matching results
|
|
72
|
+
- Extract absolute paths from the script's MEDIA: output lines
|
|
73
|
+
|
|
74
|
+
## Path Extraction
|
|
75
|
+
|
|
76
|
+
When users provide images through chat, look for log patterns like:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
Resolved relative path: "media/inbound/images/xxx.jpg" -> "/absolute/path/to/workspace/media/inbound/images/xxx.jpg"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Extract the absolute path (right side of `->`) for use with the script.
|
|
83
|
+
|
|
84
|
+
## Example Workflow
|
|
85
|
+
|
|
86
|
+
User: "Find photos with this person in my vacation folder"
|
|
87
|
+
|
|
88
|
+
1. Extract query image path from logs (or use most recent image from media/inbound/images)
|
|
89
|
+
2. Run: `uv run --with opencv-python-headless --with numpy --with requests --with sophnet-tools python /path/to/face_search.py --image_path /path/to/query.jpg --search_image_path /path/to/vacation --threshold 0.4`
|
|
90
|
+
3. **Display the results with images:**
|
|
91
|
+
- Use `read` tool to display the query face preview (the `*_face.jpg` file)
|
|
92
|
+
- Use `read` tool to display each matching result image
|
|
93
|
+
- This allows users to visually verify the matches
|
|
94
|
+
|
|
95
|
+
## Notes
|
|
96
|
+
|
|
97
|
+
- Always use absolute paths for reliability
|
|
98
|
+
- Lower threshold (e.g., 0.3) finds more matches but may include false positives
|
|
99
|
+
- Higher threshold (e.g., 0.7) is more strict but may miss some matches
|
|
100
|
+
- The script caches embeddings as JSON files to speed up repeated searches
|
|
101
|
+
|
|
102
|
+
## ⚠️ FORBIDDEN OPERATIONS
|
|
103
|
+
|
|
104
|
+
**Do NOT:**
|
|
105
|
+
|
|
106
|
+
- Spawn sub-agent sessions (sessions_spawn)
|
|
107
|
+
- Run multiple parallel face searches
|
|
108
|
+
- Split the task into separate operations
|
|
109
|
+
- Use background processes for face search
|
|
110
|
+
|
|
111
|
+
**ONLY:**
|
|
112
|
+
|
|
113
|
+
- Run the face_search.py script once with all required parameters
|
|
114
|
+
- Process all results in sequence
|
|
115
|
+
- Return complete results to the user in a single response
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sophnet-face-search"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Face search using Sophnet face detection and embedding API"
|
|
5
|
+
requires-python = ">=3.8"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"opencv-python-headless>=4.8.0",
|
|
8
|
+
"numpy>=1.24.0",
|
|
9
|
+
"requests>=2.28.0",
|
|
10
|
+
"sophnet-tools>=0.0.1",
|
|
11
|
+
]
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
人脸检索脚本
|
|
4
|
+
支持两步流程:
|
|
5
|
+
1. 输入一张查询图片,检测最大人脸并提取embedding
|
|
6
|
+
2. 输入图片列表,检测所有人脸并计算相似度
|
|
7
|
+
"""
|
|
8
|
+
import requests
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import json
|
|
12
|
+
import argparse
|
|
13
|
+
import cv2
|
|
14
|
+
import numpy as np
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
import sophnet_tools
|
|
17
|
+
|
|
18
|
+
# API配置
|
|
19
|
+
FACE_API_URL = "https://www.sophnet.com/api/open-apis/projects/detect_and_embed"
|
|
20
|
+
|
|
21
|
+
# 默认阈值
|
|
22
|
+
DEFAULT_QUERY_THRESHOLD = 0.7
|
|
23
|
+
DEFAULT_SEARCH_THRESHOLD = 0.5
|
|
24
|
+
DEFAULT_SIMILARITY_THRESHOLD = 0.4
|
|
25
|
+
|
|
26
|
+
soph_api_key = None
|
|
27
|
+
|
|
28
|
+
def detect_faces(image_path):
|
|
29
|
+
"""调用API检测人脸"""
|
|
30
|
+
if not os.path.exists(image_path):
|
|
31
|
+
raise FileNotFoundError(f"图片文件不存在: {image_path}")
|
|
32
|
+
|
|
33
|
+
ost_img = cv2.imread(image_path)
|
|
34
|
+
if ost_img is None:
|
|
35
|
+
raise ValueError(f"无法读取图片文件: {image_path}")
|
|
36
|
+
|
|
37
|
+
# 确定图片的 MIME 类型
|
|
38
|
+
input_file = Path(image_path)
|
|
39
|
+
ext = input_file.suffix.lower()
|
|
40
|
+
mime_types = {
|
|
41
|
+
'.jpg': 'image/jpeg',
|
|
42
|
+
'.jpeg': 'image/jpeg',
|
|
43
|
+
'.png': 'image/png',
|
|
44
|
+
'.webp': 'image/webp',
|
|
45
|
+
'.bmp': 'image/bmp',
|
|
46
|
+
'.gif': 'image/gif'
|
|
47
|
+
}
|
|
48
|
+
mime_type = mime_types.get(ext, 'image/jpeg')
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
with open(image_path, 'rb') as f:
|
|
52
|
+
files = {'file': (os.path.basename(image_path), f, mime_type)}
|
|
53
|
+
headers = {"Authorization": f"Bearer {soph_api_key}"}
|
|
54
|
+
# 设置较短的超时时间,避免Node.js环境中的超时溢出
|
|
55
|
+
response = requests.post(FACE_API_URL, files=files, headers=headers, timeout=90)
|
|
56
|
+
except requests.exceptions.Timeout:
|
|
57
|
+
raise RuntimeError(f"API请求超时: 图片 {image_path} 处理时间过长")
|
|
58
|
+
except requests.exceptions.RequestException as e:
|
|
59
|
+
raise RuntimeError(f"API请求异常: {str(e)}")
|
|
60
|
+
|
|
61
|
+
if response.status_code != 200:
|
|
62
|
+
raise RuntimeError(f"API请求失败: {response.status_code} - {response.text[:200]}")
|
|
63
|
+
|
|
64
|
+
return ost_img, response.json().get('result', {})
|
|
65
|
+
|
|
66
|
+
def get_largest_face(faces):
|
|
67
|
+
"""从检测到的人脸中找出尺寸最大的人脸"""
|
|
68
|
+
if not faces:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
largest_face = None
|
|
72
|
+
largest_area = 0
|
|
73
|
+
|
|
74
|
+
for face in faces:
|
|
75
|
+
box = face.get('box', [])
|
|
76
|
+
if len(box) >= 4:
|
|
77
|
+
x1, y1, x2, y2 = box[0], box[1], box[2], box[3]
|
|
78
|
+
area = (x2 - x1) * (y2 - y1)
|
|
79
|
+
if area > largest_area:
|
|
80
|
+
largest_area = area
|
|
81
|
+
largest_face = face
|
|
82
|
+
|
|
83
|
+
return largest_face
|
|
84
|
+
|
|
85
|
+
def draw_face_box_opencv(ost_img, image_path, face):
|
|
86
|
+
"""在图片上绘制人脸边界框"""
|
|
87
|
+
input_file = Path(image_path)
|
|
88
|
+
output_file = input_file.with_name(f"{input_file.stem}_face{input_file.suffix}")
|
|
89
|
+
output_file = output_file.with_suffix('.jpg')
|
|
90
|
+
output_path = str(output_file)
|
|
91
|
+
|
|
92
|
+
if face:
|
|
93
|
+
box = face.get('box', [])
|
|
94
|
+
if len(box) >= 4:
|
|
95
|
+
x1, y1, x2, y2 = int(box[0]), int(box[1]), int(box[2]), int(box[3])
|
|
96
|
+
cv2.rectangle(ost_img, (x1, y1), (x2, y2), (0, 255, 0), 3)
|
|
97
|
+
|
|
98
|
+
cv2.imwrite(output_path, ost_img)
|
|
99
|
+
return output_path
|
|
100
|
+
|
|
101
|
+
def save_embedding(face, json_path):
|
|
102
|
+
"""保存人脸embedding到json文件"""
|
|
103
|
+
if face is None:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
data = {
|
|
107
|
+
"embedding": face.get('embedding', []),
|
|
108
|
+
"box": face.get('box', []),
|
|
109
|
+
"det_score": face.get('det_score', 0)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
with open(json_path, 'w', encoding='utf-8') as f:
|
|
113
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
114
|
+
|
|
115
|
+
return json_path
|
|
116
|
+
|
|
117
|
+
def save_embeddings(image_path, faces, json_path):
|
|
118
|
+
"""保存多个人脸embedding到json文件"""
|
|
119
|
+
data = {
|
|
120
|
+
"image_path": str(image_path),
|
|
121
|
+
"embeddings": [face.get('embedding', []) for face in faces],
|
|
122
|
+
"boxes": [face.get('box', []) for face in faces],
|
|
123
|
+
"det_scores": [face.get('det_score', 0) for face in faces]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
with open(json_path, 'w', encoding='utf-8') as f:
|
|
127
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
128
|
+
|
|
129
|
+
return json_path
|
|
130
|
+
|
|
131
|
+
def get_baseface_embedding(image_path, det_thr=DEFAULT_QUERY_THRESHOLD, output_dir=None):
|
|
132
|
+
"""获取查询图片的最大人脸embedding"""
|
|
133
|
+
input_file = Path(image_path)
|
|
134
|
+
input_file_name = str(input_file)
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
ost_image, result = detect_faces(input_file_name)
|
|
138
|
+
faces_count = result.get("faces_count", 0)
|
|
139
|
+
faces = result.get("output", [])
|
|
140
|
+
faces = [face for face in faces if face.get('det_score', 0) >= det_thr]
|
|
141
|
+
faces_count = len(faces)
|
|
142
|
+
|
|
143
|
+
if faces_count > 0:
|
|
144
|
+
largest_face = get_largest_face(faces)
|
|
145
|
+
if largest_face:
|
|
146
|
+
face_image_path = draw_face_box_opencv(ost_image, input_file_name, largest_face)
|
|
147
|
+
|
|
148
|
+
# 保存embedding
|
|
149
|
+
if output_dir:
|
|
150
|
+
output_dir = Path(output_dir)
|
|
151
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
json_path = output_dir / f"{input_file.stem}_embedding.json"
|
|
153
|
+
else:
|
|
154
|
+
json_path = input_file.with_suffix('.json')
|
|
155
|
+
json_path = json_path.with_name(f"{json_path.stem}_embedding.json")
|
|
156
|
+
|
|
157
|
+
save_embedding(largest_face, str(json_path))
|
|
158
|
+
return str(json_path), str(face_image_path)
|
|
159
|
+
|
|
160
|
+
return None, None
|
|
161
|
+
|
|
162
|
+
except Exception as e:
|
|
163
|
+
print(f"错误: {e}", file=sys.stderr)
|
|
164
|
+
return None, None
|
|
165
|
+
|
|
166
|
+
def get_searchface_embeddings(image_paths, det_thr=DEFAULT_SEARCH_THRESHOLD, output_dir=None):
|
|
167
|
+
"""获取搜索图片列表的所有人脸embedding"""
|
|
168
|
+
json_list = []
|
|
169
|
+
|
|
170
|
+
if output_dir:
|
|
171
|
+
output_dir = Path(output_dir)
|
|
172
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
|
|
174
|
+
for image_path in image_paths:
|
|
175
|
+
try:
|
|
176
|
+
input_file = Path(image_path)
|
|
177
|
+
|
|
178
|
+
if output_dir:
|
|
179
|
+
json_path = output_dir / f"{input_file.stem}.json"
|
|
180
|
+
else:
|
|
181
|
+
save_path = os.path.join(os.path.dirname(input_file), ".embedding")
|
|
182
|
+
os.makedirs(save_path, exist_ok=True)
|
|
183
|
+
json_path = os.path.join(save_path, f"{input_file.stem}.json")
|
|
184
|
+
|
|
185
|
+
if os.path.exists(json_path):
|
|
186
|
+
print(f"跳过已存在的embedding文件: {json_path}")
|
|
187
|
+
json_list.append(str(json_path))
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
print(f"开始处理图片: {image_path}")
|
|
191
|
+
ost_image, result = detect_faces(image_path)
|
|
192
|
+
faces_count = result.get("faces_count", 0)
|
|
193
|
+
faces = result.get("output", [])
|
|
194
|
+
faces = [face for face in faces if face.get('det_score', 0) >= det_thr]
|
|
195
|
+
faces_count = len(faces)
|
|
196
|
+
|
|
197
|
+
save_embeddings(image_path, faces, json_path)
|
|
198
|
+
json_list.append(str(json_path))
|
|
199
|
+
|
|
200
|
+
except Exception as e:
|
|
201
|
+
print(f"处理 {image_path} 时出错: {e}", file=sys.stderr)
|
|
202
|
+
|
|
203
|
+
return json_list
|
|
204
|
+
|
|
205
|
+
def get_baseface_embedding_from_json(json_path):
|
|
206
|
+
"""从json文件加载查询人脸embedding"""
|
|
207
|
+
embeddings = []
|
|
208
|
+
with open(json_path, 'r', encoding='utf-8') as f:
|
|
209
|
+
data = json.load(f)
|
|
210
|
+
embeddings = data.get("embedding", [])
|
|
211
|
+
return np.array(embeddings).astype(np.float32)
|
|
212
|
+
|
|
213
|
+
def get_searchface_embeddings_from_json(json_paths):
|
|
214
|
+
"""从json文件列表加载搜索人脸embeddings"""
|
|
215
|
+
all_embeddings = {}
|
|
216
|
+
for json_path in json_paths:
|
|
217
|
+
with open(json_path, 'r', encoding='utf-8') as f:
|
|
218
|
+
data = json.load(f)
|
|
219
|
+
embeddings = data.get("embeddings", [])
|
|
220
|
+
image_name = data.get("image_path", "")
|
|
221
|
+
all_embeddings[image_name] = np.array(embeddings).astype(np.float32)
|
|
222
|
+
return all_embeddings
|
|
223
|
+
|
|
224
|
+
def cosine_similarity(vec1, vec2):
|
|
225
|
+
"""计算余弦相似度"""
|
|
226
|
+
dot_product = np.dot(vec1, vec2)
|
|
227
|
+
norm1 = np.linalg.norm(vec1)
|
|
228
|
+
norm2 = np.linalg.norm(vec2)
|
|
229
|
+
if norm1 == 0 or norm2 == 0:
|
|
230
|
+
return 0
|
|
231
|
+
return dot_product / (norm1 * norm2)
|
|
232
|
+
|
|
233
|
+
def search_similar_faces(base_json_path, search_json_paths, threshold=DEFAULT_SIMILARITY_THRESHOLD):
|
|
234
|
+
"""搜索相似人脸"""
|
|
235
|
+
if not base_json_path or not search_json_paths:
|
|
236
|
+
return []
|
|
237
|
+
|
|
238
|
+
face_embedding = get_baseface_embedding_from_json(base_json_path)
|
|
239
|
+
search_embeddings = get_searchface_embeddings_from_json(search_json_paths)
|
|
240
|
+
|
|
241
|
+
results = []
|
|
242
|
+
for image_name, embeddings in search_embeddings.items():
|
|
243
|
+
for idx, embedding in enumerate(embeddings):
|
|
244
|
+
similarity = cosine_similarity(face_embedding, embedding)
|
|
245
|
+
if similarity >= threshold:
|
|
246
|
+
results.append({
|
|
247
|
+
"image_path": image_name,
|
|
248
|
+
"face_index": idx,
|
|
249
|
+
"similarity": float(similarity)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
# 按相似度降序排序
|
|
253
|
+
results.sort(key=lambda x: x["similarity"], reverse=True)
|
|
254
|
+
return results
|
|
255
|
+
|
|
256
|
+
def list_images_pathlib(folder_path):
|
|
257
|
+
"""使用 pathlib 递归列出所有图片文件"""
|
|
258
|
+
image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.tif', '.ico', '.svg'}
|
|
259
|
+
folder = Path(folder_path).resolve()
|
|
260
|
+
image_files = []
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
for file_path in folder.rglob('*'):
|
|
264
|
+
try:
|
|
265
|
+
if file_path.is_file() and file_path.suffix.lower() in image_extensions:
|
|
266
|
+
image_files.append(str(file_path))
|
|
267
|
+
except PermissionError:
|
|
268
|
+
print(f"[警告] 无权限访问:{file_path}")
|
|
269
|
+
continue
|
|
270
|
+
except PermissionError:
|
|
271
|
+
print(f"[错误] 无权限访问根目录:{folder}")
|
|
272
|
+
|
|
273
|
+
return image_files
|
|
274
|
+
|
|
275
|
+
def main():
|
|
276
|
+
parser = argparse.ArgumentParser(
|
|
277
|
+
description="人脸检索工具 - 在图片库中搜索相似人脸"
|
|
278
|
+
)
|
|
279
|
+
parser.add_argument('--image_path', required=True, help='查询图片路径')
|
|
280
|
+
parser.add_argument('--search_image_path', required=True, help='搜索图片文件夹路径')
|
|
281
|
+
parser.add_argument('--det-thr', type=float, default=DEFAULT_SEARCH_THRESHOLD,
|
|
282
|
+
help=f'检测阈值 (默认: {DEFAULT_SEARCH_THRESHOLD})')
|
|
283
|
+
parser.add_argument('--threshold', type=float, default=DEFAULT_SIMILARITY_THRESHOLD,
|
|
284
|
+
help=f'相似度阈值 (默认: {DEFAULT_SIMILARITY_THRESHOLD})')
|
|
285
|
+
|
|
286
|
+
args = parser.parse_args()
|
|
287
|
+
|
|
288
|
+
global soph_api_key
|
|
289
|
+
try:
|
|
290
|
+
soph_api_key = sophnet_tools.get_api_key()
|
|
291
|
+
except RuntimeError as e:
|
|
292
|
+
print(f"错误: {e}", file=sys.stderr)
|
|
293
|
+
sys.exit(1)
|
|
294
|
+
|
|
295
|
+
# 步骤1: 提取查询人脸
|
|
296
|
+
print(f"正在分析查询图片: {args.image_path}")
|
|
297
|
+
base_json_path, face_image_path = get_baseface_embedding(args.image_path, args.det_thr)
|
|
298
|
+
|
|
299
|
+
if not base_json_path:
|
|
300
|
+
print("错误: 未在查询图片中检测到人脸", file=sys.stderr)
|
|
301
|
+
sys.exit(1)
|
|
302
|
+
|
|
303
|
+
print(f"查询人脸embedding已保存: {base_json_path}")
|
|
304
|
+
print(f"要搜索的人脸预览: MEDIA:{face_image_path}")
|
|
305
|
+
|
|
306
|
+
# 步骤2: 扫描搜索目录
|
|
307
|
+
image_list = list_images_pathlib(args.search_image_path)
|
|
308
|
+
print(f"\n在 {args.search_image_path} 中找到 {len(image_list)} 张图片")
|
|
309
|
+
|
|
310
|
+
if len(image_list) == 0:
|
|
311
|
+
print("错误: 搜索图片路径中未找到任何图片", file=sys.stderr)
|
|
312
|
+
sys.exit(1)
|
|
313
|
+
|
|
314
|
+
# 步骤3: 提取所有人脸embeddings
|
|
315
|
+
print("\n正在提取搜索图片中的人脸...")
|
|
316
|
+
json_paths = get_searchface_embeddings(image_list, args.det_thr)
|
|
317
|
+
|
|
318
|
+
if not json_paths:
|
|
319
|
+
print("错误: 搜索目录中未检测到任何人脸", file=sys.stderr)
|
|
320
|
+
sys.exit(1)
|
|
321
|
+
|
|
322
|
+
# 步骤4: 搜索相似人脸
|
|
323
|
+
print(f"\n正在搜索相似人脸 (阈值: {args.threshold})...")
|
|
324
|
+
results = search_similar_faces(base_json_path, json_paths, args.threshold)
|
|
325
|
+
|
|
326
|
+
if results:
|
|
327
|
+
print(f"\n找到 {len(results)} 个相似人脸:\n")
|
|
328
|
+
for r in results:
|
|
329
|
+
similarity_percent = r['similarity'] * 100
|
|
330
|
+
print(f" {r['image_path']} (人脸#{r['face_index']}, 相似度: {similarity_percent:.2f}%)")
|
|
331
|
+
print(f" MEDIA:{r['image_path']}")
|
|
332
|
+
else:
|
|
333
|
+
print(f"\n未找到相似度超过 {args.threshold} 的人脸")
|
|
334
|
+
|
|
335
|
+
if __name__ == "__main__":
|
|
336
|
+
main()
|