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.
Files changed (125) hide show
  1. package/bin/sophhub.js +21 -0
  2. package/package.json +32 -0
  3. package/skills/VERSIONS.md +27 -0
  4. package/skills/builtin/clawhub/SKILL.md +77 -0
  5. package/skills/builtin/flight-booking/SKILL.md +288 -0
  6. package/skills/builtin/flight-booking/scripts/flight_booking.py +1232 -0
  7. package/skills/builtin/inventory-management/SKILL.md +241 -0
  8. package/skills/builtin/inventory-management/scripts/inventory.py +1844 -0
  9. package/skills/builtin/schedule-reminder/SKILL.md +619 -0
  10. package/skills/builtin/schedule-reminder/schedule_template.md +68 -0
  11. package/skills/builtin/schedule-reminder/scripts/append_event.py +204 -0
  12. package/skills/builtin/schedule-reminder/scripts/create_reminders.sh +163 -0
  13. package/skills/builtin/schedule-reminder/scripts/daily_activate.sh +175 -0
  14. package/skills/builtin/schedule-reminder/scripts/parse_schedule.py +704 -0
  15. package/skills/builtin/schedule-reminder/scripts/setup.sh +242 -0
  16. package/skills/builtin/schedule-reminder//347/224/250/346/210/267/346/214/207/345/215/227.md +311 -0
  17. package/skills/builtin/skill-creator/SKILL.md +370 -0
  18. package/skills/builtin/skill-creator/license.txt +202 -0
  19. package/skills/builtin/skill-creator/scripts/init_skill.py +378 -0
  20. package/skills/builtin/skill-creator/scripts/package_skill.py +111 -0
  21. package/skills/builtin/skill-creator/scripts/quick_validate.py +101 -0
  22. package/skills/builtin/sophnet-customer-management/SKILL.md +271 -0
  23. package/skills/builtin/sophnet-customer-management/pyproject.toml +15 -0
  24. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/__init__.py +2 -0
  25. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/__main__.py +5 -0
  26. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/cli.py +67 -0
  27. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/__init__.py +2 -0
  28. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/customer.py +60 -0
  29. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/export_file.py +18 -0
  30. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/import_file.py +15 -0
  31. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/reminder.py +26 -0
  32. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/schema.py +28 -0
  33. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/config.py +54 -0
  34. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/__init__.py +2 -0
  35. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/exporter.py +85 -0
  36. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/models.py +84 -0
  37. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/normalizer.py +144 -0
  38. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/parser.py +241 -0
  39. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/query.py +109 -0
  40. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/reminder.py +121 -0
  41. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/repository.py +397 -0
  42. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/schema.py +106 -0
  43. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/service.py +565 -0
  44. package/skills/builtin/sophnet-customer-management/uv.lock +48 -0
  45. package/skills/builtin/sophnet-customized-marketing/SKILL.md +144 -0
  46. package/skills/builtin/sophnet-customized-marketing/playbooks/campaign-planning.md +187 -0
  47. package/skills/builtin/sophnet-customized-marketing/playbooks/content-generation.md +124 -0
  48. package/skills/builtin/sophnet-customized-marketing/playbooks/marketing-calendar.md +59 -0
  49. package/skills/builtin/sophnet-customized-marketing/playbooks/multi-channel-bundle.md +94 -0
  50. package/skills/builtin/sophnet-customized-marketing/playbooks/poster-generation.md +182 -0
  51. package/skills/builtin/sophnet-customized-marketing/playbooks/style-profile-workflow.md +103 -0
  52. package/skills/builtin/sophnet-customized-marketing/pyproject.toml +9 -0
  53. package/skills/builtin/sophnet-customized-marketing/references/campaign-mechanics.md +168 -0
  54. package/skills/builtin/sophnet-customized-marketing/references/content-safety.md +26 -0
  55. package/skills/builtin/sophnet-customized-marketing/references/marketing-date-checklist.md +99 -0
  56. package/skills/builtin/sophnet-customized-marketing/references/platform-writing-guidelines.md +88 -0
  57. package/skills/builtin/sophnet-customized-marketing/references/quality-checklist.md +44 -0
  58. package/skills/builtin/sophnet-customized-marketing/scripts/generate_poster.py +585 -0
  59. package/skills/builtin/sophnet-customized-marketing/scripts/style_profile.py +215 -0
  60. package/skills/builtin/sophnet-face-search/SKILL.md +115 -0
  61. package/skills/builtin/sophnet-face-search/pyproject.toml +11 -0
  62. package/skills/builtin/sophnet-face-search/scripts/face_search.py +336 -0
  63. package/skills/builtin/sophnet-face-search/uv.lock +508 -0
  64. package/skills/builtin/sophnet-image-edit/SKILL.md +140 -0
  65. package/skills/builtin/sophnet-image-edit/pyproject.toml +9 -0
  66. package/skills/builtin/sophnet-image-edit/scripts/edit_and_preview.sh +68 -0
  67. package/skills/builtin/sophnet-image-edit/scripts/edit_image.py +279 -0
  68. package/skills/builtin/sophnet-image-edit/uv.lock +234 -0
  69. package/skills/builtin/sophnet-image-generate/SKILL.md +62 -0
  70. package/skills/builtin/sophnet-image-generate/pyproject.toml +9 -0
  71. package/skills/builtin/sophnet-image-generate/scripts/generate_image.py +156 -0
  72. package/skills/builtin/sophnet-image-generate/uv.lock +234 -0
  73. package/skills/builtin/sophnet-image-ocr/SKILL.md +167 -0
  74. package/skills/builtin/sophnet-image-ocr/pyproject.toml +13 -0
  75. package/skills/builtin/sophnet-image-ocr/scripts/ocr.py +226 -0
  76. package/skills/builtin/sophnet-image-ocr/uv.lock +234 -0
  77. package/skills/builtin/sophnet-infinite-talk/SKILL.md +140 -0
  78. package/skills/builtin/sophnet-infinite-talk/pyproject.toml +9 -0
  79. package/skills/builtin/sophnet-infinite-talk/scripts/gen.py +172 -0
  80. package/skills/builtin/sophnet-oss/SKILL.md +109 -0
  81. package/skills/builtin/sophnet-oss/pyproject.toml +8 -0
  82. package/skills/builtin/sophnet-oss/scripts/upload_file.py +43 -0
  83. package/skills/builtin/sophnet-qa-install/SKILL.md +210 -0
  84. package/skills/builtin/sophnet-qa-install/pyproject.toml +6 -0
  85. package/skills/builtin/sophnet-qa-install/scripts/backup_md.py +35 -0
  86. package/skills/builtin/sophnet-qa-install/scripts/check_installed.py +143 -0
  87. package/skills/builtin/sophnet-qa-install/scripts/update_config.py +142 -0
  88. package/skills/builtin/sophnet-qa-install/scripts/update_md.py +73 -0
  89. package/skills/builtin/sophnet-training-install/SKILL.md +211 -0
  90. package/skills/builtin/sophnet-training-install/pyproject.toml +6 -0
  91. package/skills/builtin/sophnet-training-install/scripts/backup_md.py +35 -0
  92. package/skills/builtin/sophnet-training-install/scripts/check_installed.py +144 -0
  93. package/skills/builtin/sophnet-training-install/scripts/update_config.py +142 -0
  94. package/skills/builtin/sophnet-training-install/scripts/update_md.py +73 -0
  95. package/skills/builtin/sophnet-tts/SKILL.md +79 -0
  96. package/skills/builtin/sophnet-tts/pyproject.toml +9 -0
  97. package/skills/builtin/sophnet-tts/scripts/gen_tts.py +130 -0
  98. package/skills/builtin/sophnet-video-generate/SKILL.md +116 -0
  99. package/skills/builtin/sophnet-video-generate/scripts/gen_video.py +304 -0
  100. package/skills/builtin/video-understand/SKILL.md +79 -0
  101. package/skills/builtin/video-understand/scripts/video_understand.py +204 -0
  102. package/skills/builtin/weather/SKILL.md +112 -0
  103. package/skills/builtin/web-scraper/SKILL.md +101 -0
  104. package/skills/builtin/web-scraper/scripts/scrape.py +270 -0
  105. package/skills/builtin/website-builder/SKILL.md +266 -0
  106. package/skills/builtin/website-builder/scripts/deploy_site.sh +46 -0
  107. package/skills/store/didi-ride/SKILL.md +309 -0
  108. package/skills/store/didi-ride/_meta.json +6 -0
  109. package/skills/store/didi-ride/assets/PREFERENCE.md +58 -0
  110. package/skills/store/didi-ride/package.json +15 -0
  111. package/skills/store/didi-ride/references/api_references.md +171 -0
  112. package/skills/store/didi-ride/references/error_handling.md +68 -0
  113. package/skills/store/didi-ride/references/setup.md +73 -0
  114. package/skills/store/didi-ride/references/workflow.md +150 -0
  115. package/skills/store/flyai/SKILL.md +119 -0
  116. package/skills/store/flyai/references/fliggy-fast-search.md +53 -0
  117. package/skills/store/flyai/references/search-flight.md +89 -0
  118. package/skills/store/flyai/references/search-hotels.md +57 -0
  119. package/skills/store/flyai/references/search-poi.md +49 -0
  120. package/src/commands/download.js +103 -0
  121. package/src/commands/list.js +67 -0
  122. package/src/utils/config.js +24 -0
  123. package/src/utils/gitlab.js +67 -0
  124. package/src/utils/paths.js +19 -0
  125. 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()