sophhub 0.4.24 → 0.4.25
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/package.json +1 -1
- package/skills/agent-install/skill.json +9 -2
- package/skills/agent-install/src/SKILL.md +5 -8
- package/skills/agent-install/src/pyproject.toml +1 -1
- package/skills/agent-install/src/scripts/common.py +24 -0
- package/skills/agent-install/src/scripts/install_agent_skills.py +162 -0
- package/skills/image-classify/skill.json +10 -3
- package/skills/image-classify/src/SKILL.md +33 -33
- package/skills/image-classify/src/pyproject.toml +11 -0
- package/skills/image-classify/src/scripts/face_search.py +271 -58
- package/skills/image-classify/src/references/config.json +0 -4
package/package.json
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-install",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"types": [
|
|
5
5
|
"store"
|
|
6
6
|
],
|
|
7
7
|
"displayName": "Agent安装",
|
|
8
8
|
"description": "通用 Agent 安装与升级。",
|
|
9
9
|
"changelog": [
|
|
10
|
+
{
|
|
11
|
+
"changes": [
|
|
12
|
+
"新增 install_agent_skills.py:Agent 安装/升级时重新下载 auto_install skill 并直接替换 workspace/skills 目录"
|
|
13
|
+
],
|
|
14
|
+
"date": "2026-05-19",
|
|
15
|
+
"version": "0.1.6"
|
|
16
|
+
},
|
|
10
17
|
{
|
|
11
18
|
"changes": [
|
|
12
19
|
"update_openclaw 根据 .config.json 的 auto_generate_image_description 同步自动生成图片描述开关(默认开启)"
|
|
@@ -30,5 +37,5 @@
|
|
|
30
37
|
}
|
|
31
38
|
],
|
|
32
39
|
"createdAt": "2026-04-21",
|
|
33
|
-
"updatedAt": "2026-05-
|
|
40
|
+
"updatedAt": "2026-05-19"
|
|
34
41
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: agent-install
|
|
3
3
|
description: 安装或升级通用 OpenClaw Agent(含按 .config.json 自动下载 skill)。Use when the user asks to install an agent, upgrade an agent, download agent config, back up an existing agent, update openclaw.json, or install skills listed with auto_install.
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.6
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# Agent Install
|
|
@@ -191,17 +191,14 @@ uv run {baseDir}/scripts/update_openclaw.py \
|
|
|
191
191
|
|
|
192
192
|
在 Step 3 成功、`openclaw.json` 已写入后执行。
|
|
193
193
|
|
|
194
|
-
**Skill
|
|
194
|
+
**Skill 重新安装**:读 workspace 内 `.config.json` 的 `skills`,对 **`auto_install: true`** 的条目**依次**下载并**直接替换** `{workspace}/skills/{skill_name}` 目录(先删旧目录再写入新包;本地对该 skill 的改动会被覆盖):
|
|
195
195
|
|
|
196
196
|
```bash
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
```bash
|
|
201
|
-
npx -y sophhub@latest download {skill_name} -o "{workspace}/skills"
|
|
197
|
+
uv run {baseDir}/scripts/install_agent_skills.py \
|
|
198
|
+
--workspace "{workspace}"
|
|
202
199
|
```
|
|
203
200
|
|
|
204
|
-
`{workspace}` 为本 Agent
|
|
201
|
+
`{workspace}` 为本 Agent 工作目录。新装与升级/重置均须执行本步。
|
|
205
202
|
|
|
206
203
|
**输出模版**
|
|
207
204
|
|
|
@@ -185,6 +185,30 @@ def normalize_skills(skills: Any) -> list[str]:
|
|
|
185
185
|
return list(dict.fromkeys(result))
|
|
186
186
|
|
|
187
187
|
|
|
188
|
+
def get_auto_install_skills(agent_def: dict[str, Any]) -> list[dict[str, Any]]:
|
|
189
|
+
"""返回 .config.json 中 auto_install: true 的 skill 条目(保序、去重)。"""
|
|
190
|
+
skills = agent_def.get("skills")
|
|
191
|
+
if not isinstance(skills, list):
|
|
192
|
+
return []
|
|
193
|
+
|
|
194
|
+
result: list[dict[str, Any]] = []
|
|
195
|
+
seen: set[str] = set()
|
|
196
|
+
for item in skills:
|
|
197
|
+
if not isinstance(item, dict):
|
|
198
|
+
continue
|
|
199
|
+
if item.get("auto_install") is not True:
|
|
200
|
+
continue
|
|
201
|
+
name = item.get("name")
|
|
202
|
+
if not isinstance(name, str) or not name.strip():
|
|
203
|
+
continue
|
|
204
|
+
skill_name = name.strip()
|
|
205
|
+
if skill_name in seen:
|
|
206
|
+
continue
|
|
207
|
+
seen.add(skill_name)
|
|
208
|
+
result.append(item)
|
|
209
|
+
return result
|
|
210
|
+
|
|
211
|
+
|
|
188
212
|
def normalize_dependencies(dependencies: Any) -> list[str]:
|
|
189
213
|
if not isinstance(dependencies, list):
|
|
190
214
|
return []
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from common import get_auto_install_skills, load_agent_definition, load_json
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run_sophhub_download(skill_name: str, output_dir: Path, *, timeout: int = 300) -> subprocess.CompletedProcess[str]:
|
|
16
|
+
return subprocess.run(
|
|
17
|
+
["npx", "-y", "sophhub@latest", "download", skill_name, "-o", str(output_dir)],
|
|
18
|
+
capture_output=True,
|
|
19
|
+
text=True,
|
|
20
|
+
check=False,
|
|
21
|
+
timeout=timeout,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def replace_skill_directory(source_dir: Path, target_dir: Path) -> None:
|
|
26
|
+
"""删除目标 skill 目录并用新下载内容完整替换。"""
|
|
27
|
+
if target_dir.exists():
|
|
28
|
+
shutil.rmtree(target_dir)
|
|
29
|
+
shutil.copytree(source_dir, target_dir)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def install_single_skill(skill_name: str, skills_dir: Path) -> dict[str, object]:
|
|
33
|
+
target_dir = skills_dir / skill_name
|
|
34
|
+
|
|
35
|
+
with tempfile.TemporaryDirectory(prefix=f"agent-install-skill-{skill_name}-") as tmp:
|
|
36
|
+
tmp_path = Path(tmp)
|
|
37
|
+
proc = run_sophhub_download(skill_name, tmp_path)
|
|
38
|
+
|
|
39
|
+
downloaded_dir = tmp_path / skill_name
|
|
40
|
+
if proc.returncode != 0:
|
|
41
|
+
return {
|
|
42
|
+
"name": skill_name,
|
|
43
|
+
"ok": False,
|
|
44
|
+
"reason": "download_failed",
|
|
45
|
+
"returncode": proc.returncode,
|
|
46
|
+
"stdout": proc.stdout.strip(),
|
|
47
|
+
"stderr": proc.stderr.strip(),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if not downloaded_dir.is_dir():
|
|
51
|
+
return {
|
|
52
|
+
"name": skill_name,
|
|
53
|
+
"ok": False,
|
|
54
|
+
"reason": "download_missing_directory",
|
|
55
|
+
"expected_path": str(downloaded_dir),
|
|
56
|
+
"stdout": proc.stdout.strip(),
|
|
57
|
+
"stderr": proc.stderr.strip(),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
skills_dir.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
had_existing = target_dir.exists()
|
|
62
|
+
replace_skill_directory(downloaded_dir, target_dir)
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
"name": skill_name,
|
|
66
|
+
"ok": True,
|
|
67
|
+
"target_dir": str(target_dir),
|
|
68
|
+
"replaced": had_existing,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_agent_def_for_workspace(
|
|
73
|
+
workspace: Path,
|
|
74
|
+
*,
|
|
75
|
+
agent_id: str | None = None,
|
|
76
|
+
source_path: Path | None = None,
|
|
77
|
+
) -> dict[str, object]:
|
|
78
|
+
config_path = workspace / ".config.json"
|
|
79
|
+
if config_path.is_file():
|
|
80
|
+
return load_json(config_path)
|
|
81
|
+
if agent_id:
|
|
82
|
+
return load_agent_definition(agent_id, source_path)
|
|
83
|
+
raise ValueError("workspace 下无 .config.json,请提供 --agent-id")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def install_agent_skills(
|
|
87
|
+
workspace: Path,
|
|
88
|
+
*,
|
|
89
|
+
agent_id: str | None = None,
|
|
90
|
+
source_path: Path | None = None,
|
|
91
|
+
) -> dict[str, object]:
|
|
92
|
+
agent_def = load_agent_def_for_workspace(workspace, agent_id=agent_id, source_path=source_path)
|
|
93
|
+
auto_install = get_auto_install_skills(agent_def)
|
|
94
|
+
skills_dir = workspace / "skills"
|
|
95
|
+
|
|
96
|
+
if not auto_install:
|
|
97
|
+
return {
|
|
98
|
+
"ok": True,
|
|
99
|
+
"workspace": str(workspace),
|
|
100
|
+
"skills_dir": str(skills_dir),
|
|
101
|
+
"installed": [],
|
|
102
|
+
"failed": [],
|
|
103
|
+
"details": [],
|
|
104
|
+
"message": "无 auto_install skill,跳过。",
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
results: list[dict[str, object]] = []
|
|
108
|
+
for item in auto_install:
|
|
109
|
+
skill_name = str(item["name"])
|
|
110
|
+
results.append(install_single_skill(skill_name, skills_dir))
|
|
111
|
+
|
|
112
|
+
failed = [item for item in results if not item.get("ok")]
|
|
113
|
+
installed = [str(item["name"]) for item in results if item.get("ok")]
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
"ok": not failed,
|
|
117
|
+
"workspace": str(workspace),
|
|
118
|
+
"skills_dir": str(skills_dir),
|
|
119
|
+
"installed": installed,
|
|
120
|
+
"failed": [
|
|
121
|
+
{
|
|
122
|
+
"name": str(item["name"]),
|
|
123
|
+
"reason": item.get("reason"),
|
|
124
|
+
"stderr": item.get("stderr"),
|
|
125
|
+
}
|
|
126
|
+
for item in failed
|
|
127
|
+
],
|
|
128
|
+
"details": results,
|
|
129
|
+
"message": (
|
|
130
|
+
f"已重新安装 {len(installed)} 个 skill(直接替换目录)。"
|
|
131
|
+
if not failed
|
|
132
|
+
else f"{len(failed)} 个 skill 安装失败,已成功 {len(installed)} 个。"
|
|
133
|
+
),
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def main() -> int:
|
|
138
|
+
parser = argparse.ArgumentParser(description="安装或重新安装 Agent 的 auto_install skills(直接替换目录)")
|
|
139
|
+
parser.add_argument("--workspace", required=True, help="Agent workspace 路径")
|
|
140
|
+
parser.add_argument("--agent-id", help="Agent ID(workspace 尚无 .config.json 时使用)")
|
|
141
|
+
parser.add_argument("--source-path", help="Agent 下载目录")
|
|
142
|
+
args = parser.parse_args()
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
result = install_agent_skills(
|
|
146
|
+
Path(args.workspace).expanduser().resolve(),
|
|
147
|
+
agent_id=args.agent_id,
|
|
148
|
+
source_path=Path(args.source_path).expanduser().resolve() if args.source_path else None,
|
|
149
|
+
)
|
|
150
|
+
except (ValueError, FileNotFoundError) as exc:
|
|
151
|
+
payload = {"ok": False, "message": str(exc)}
|
|
152
|
+
json.dump(payload, sys.stdout, indent=2, ensure_ascii=False)
|
|
153
|
+
sys.stdout.write("\n")
|
|
154
|
+
return 1
|
|
155
|
+
|
|
156
|
+
json.dump(result, sys.stdout, indent=2, ensure_ascii=False)
|
|
157
|
+
sys.stdout.write("\n")
|
|
158
|
+
return 0 if result.get("ok") else 1
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
if __name__ == "__main__":
|
|
162
|
+
raise SystemExit(main())
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "image-classify",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"types": [
|
|
5
5
|
"store"
|
|
6
6
|
],
|
|
7
7
|
"displayName": "照片分类器",
|
|
8
|
-
"description": "
|
|
8
|
+
"description": "照片分类器:注册人脸、按人搜索/一键分类相册。当用户说用照片分类器查找或搜索某人、对文件夹分类、添加/删除照片分类器用户时使用。",
|
|
9
9
|
"changelog": [
|
|
10
|
+
{
|
|
11
|
+
"version": "1.0.5",
|
|
12
|
+
"date": "2026-05-19",
|
|
13
|
+
"changes": [
|
|
14
|
+
"配置与注册照片迁至与 images 同级的 .image-classify/ 目录;阈值改为脚本常量;首次运行从 .references/ 或 skill 内 references/ 自动迁移并删除旧目录"
|
|
15
|
+
]
|
|
16
|
+
},
|
|
10
17
|
{
|
|
11
18
|
"version": "1.0.4",
|
|
12
19
|
"date": "2026-05-18",
|
|
@@ -45,5 +52,5 @@
|
|
|
45
52
|
}
|
|
46
53
|
],
|
|
47
54
|
"createdAt": "2026-04-10",
|
|
48
|
-
"updatedAt": "2026-05-
|
|
55
|
+
"updatedAt": "2026-05-19"
|
|
49
56
|
}
|
|
@@ -17,13 +17,14 @@ description: 照片分类器。通过人脸识别对照片进行分类、搜索
|
|
|
17
17
|
|
|
18
18
|
- Python 3.10+、uv
|
|
19
19
|
- 依赖:`requests`, `opencv-python`, `numpy`, `sophnet_tools`(项目内置)
|
|
20
|
-
- 配置文件:`{
|
|
20
|
+
- 配置文件:`{dataRoot}/.image-classify/config.json`(与 `images` 同级;首次运行可从 `.references/` 或 `{baseDir}/references/` 迁移)
|
|
21
21
|
- 若使用 **DM 自动推送**(注册时 `--friend-id`、一键 `classify` 后推送结果),需本机存在有效 JWT:`/home/node/.openclaw/jwt.json`(与 `send_dm_message.py` 一致)
|
|
22
22
|
|
|
23
23
|
## 核心概念
|
|
24
24
|
|
|
25
25
|
- **`{baseDir}`**:本 skill 根目录,即 `skills/image-classify`,调用时替换为实际绝对路径。
|
|
26
|
-
-
|
|
26
|
+
- **`{dataRoot}`**:与 `images` 同级的工作区根目录(由脚本自动解析,本仓库内通常为 `skills/`)。
|
|
27
|
+
- **配置文件**:`{dataRoot}/.image-classify/config.json`,存储注册用户、照片路径(位于 `.image-classify/`)、人脸 embedding、搜索结果,以及可选 **DM 绑定**(`friendId` / `friendLabel`;旧字段 `xia_you_hao` / `xia_you_label` 仍兼容)。
|
|
27
28
|
- **脚本入口**:`{baseDir}/scripts/face_search.py`,通过 `uv run` 以子命令方式调用。
|
|
28
29
|
- **所有命令输出均为 JSON 格式**,方便解析结果。
|
|
29
30
|
|
|
@@ -33,7 +34,7 @@ description: 照片分类器。通过人脸识别对照片进行分类、搜索
|
|
|
33
34
|
uv run {baseDir}/scripts/face_search.py [-c CONFIG_PATH] <command> [args...]
|
|
34
35
|
```
|
|
35
36
|
|
|
36
|
-
`-c` / `--config`
|
|
37
|
+
`-c` / `--config` 可选;省略时自动使用 `{dataRoot}/.image-classify/config.json`。
|
|
37
38
|
|
|
38
39
|
## 可用命令一览
|
|
39
40
|
|
|
@@ -63,12 +64,12 @@ uv run {baseDir}/scripts/face_search.py [-c CONFIG_PATH] <command> [args...]
|
|
|
63
64
|
|
|
64
65
|
### 未指定搜索目录时(`search` / `quick-search` / `classify` 共用)
|
|
65
66
|
|
|
66
|
-
|
|
67
|
+
待搜索根目录为 `{dataRoot}/images`(与 `.image-classify` 同级,由脚本自动解析)。其下按日期命名的子目录(`YYYYMMDD`)为可选搜索范围。
|
|
67
68
|
|
|
68
69
|
若用户**未说明**搜索目录(或只说「默认目录」「最近上传的」等),**不要猜测路径**,先执行:
|
|
69
70
|
|
|
70
71
|
```bash
|
|
71
|
-
uv run {baseDir}/scripts/face_search.py
|
|
72
|
+
uv run {baseDir}/scripts/face_search.py list-date-folders
|
|
72
73
|
```
|
|
73
74
|
|
|
74
75
|
输出 JSON 字段说明:
|
|
@@ -96,20 +97,20 @@ uv run {baseDir}/scripts/face_search.py -c {baseDir}/references/config.json list
|
|
|
96
97
|
|
|
97
98
|
**流程**:
|
|
98
99
|
|
|
99
|
-
1. 获取用户提供的**用户名**、**照片路径**,以及是否绑定 **DM 接收方**(均可选)。用户常以自然语言写出类似:**虾友:xxx,ID:23725
|
|
100
|
+
1. 获取用户提供的**用户名**、**照片路径**,以及是否绑定 **DM 接收方**(均可选)。用户常以自然语言写出类似:**虾友:xxx,ID:23725**。解析规则:**xxx** → `--friend-label`;**23725** → `--friend-id`(正整数)。可只填 `friendId`、不填展示名。
|
|
100
101
|
2. 检查用户名是否已存在:
|
|
101
102
|
```bash
|
|
102
|
-
uv run {baseDir}/scripts/face_search.py
|
|
103
|
+
uv run {baseDir}/scripts/face_search.py check "张三"
|
|
103
104
|
```
|
|
104
105
|
输出:`{"exists": true/false, "name": "张三"}`
|
|
105
106
|
|
|
106
107
|
3. **如果不存在** → 注册新用户(无虾友号):
|
|
107
108
|
```bash
|
|
108
|
-
uv run {baseDir}/scripts/face_search.py
|
|
109
|
+
uv run {baseDir}/scripts/face_search.py add "张三" "/path/to/photo.jpg"
|
|
109
110
|
```
|
|
110
111
|
若用户提供了 `friendId`,追加 `--friend-id <正整数>`;若有展示名,追加 `--friend-label "xxx"`(注意转义或引号,避免 shell 拆词):
|
|
111
112
|
```bash
|
|
112
|
-
uv run {baseDir}/scripts/face_search.py
|
|
113
|
+
uv run {baseDir}/scripts/face_search.py add "张三" "/path/to/photo.jpg" --friend-label "小李" --friend-id 23725
|
|
113
114
|
```
|
|
114
115
|
输出:`{"success": true/false, "message": "...", "name": "张三", "image_url": "https://...", "friendId": 23725, "friendLabel": "小李"}`(`friendId` / `friendLabel` 仅在实际传入时出现)
|
|
115
116
|
- `success: true` → 提示:`🎉✨ **注册成功** · xxx`,并使用返回的 `image_url` 展示照片:``;若返回含 `friendId` / `friendLabel`,可顺带一句已绑定 DM(不必冗长)
|
|
@@ -124,19 +125,19 @@ uv run {baseDir}/scripts/face_search.py -c {baseDir}/references/config.json add
|
|
|
124
125
|
```
|
|
125
126
|
- **更新照片**:
|
|
126
127
|
```bash
|
|
127
|
-
uv run {baseDir}/scripts/face_search.py
|
|
128
|
+
uv run {baseDir}/scripts/face_search.py replace "张三" "/path/to/new.jpg"
|
|
128
129
|
```
|
|
129
130
|
输出包含 `image_url` 字段。
|
|
130
131
|
- 成功:`✅🔄 **照片已更新** · xxx`,使用 `image_url` 展示照片:``
|
|
131
132
|
- 失败:`❌🙈 照片中未发现有效的人脸信息,更新失败!`
|
|
132
133
|
- **重新命名**:
|
|
133
134
|
```bash
|
|
134
|
-
uv run {baseDir}/scripts/face_search.py
|
|
135
|
+
uv run {baseDir}/scripts/face_search.py rename "张三" "李三"
|
|
135
136
|
```
|
|
136
137
|
- 提示:`✏️📛 **用户名已更新** · xxx → yyy`
|
|
137
138
|
- **保留多张**:
|
|
138
139
|
```bash
|
|
139
|
-
uv run {baseDir}/scripts/face_search.py
|
|
140
|
+
uv run {baseDir}/scripts/face_search.py append "张三" "/path/to/extra.jpg"
|
|
140
141
|
```
|
|
141
142
|
输出包含 `image_url` 字段。
|
|
142
143
|
- 成功:`✅➕ **新照片已追加** · xxx`,使用 `image_url` 展示照片:``
|
|
@@ -152,7 +153,7 @@ uv run {baseDir}/scripts/face_search.py -c {baseDir}/references/config.json add
|
|
|
152
153
|
2. 检查用户名是否存在(`check` 命令),不存在则提示:`❌🔍 **未找到该用户** · 请确认姓名后重试`
|
|
153
154
|
3. 使用**子会话**执行搜索(可能耗时较长):
|
|
154
155
|
```bash
|
|
155
|
-
uv run {baseDir}/scripts/face_search.py
|
|
156
|
+
uv run {baseDir}/scripts/face_search.py search "张三" "<dir>"
|
|
156
157
|
```
|
|
157
158
|
(`<dir>` 为用户指定路径,或从 `list-date-folders` 选中的 `path`。)**命令返回后立刻关闭并删除该子会话。**
|
|
158
159
|
输出:
|
|
@@ -178,7 +179,7 @@ uv run {baseDir}/scripts/face_search.py -c {baseDir}/references/config.json sear
|
|
|
178
179
|
1. 获取用户提供的**照片路径**和**搜索目录**。若**未指定搜索目录**,先按「未指定搜索目录时」列出最近 3 个日期文件夹供选择(支持「更多」),得到 `<dir>` 后再继续。
|
|
179
180
|
2. 使用**子会话**执行搜索(先提取人脸 embedding,再匹配,可能耗时较长):
|
|
180
181
|
```bash
|
|
181
|
-
uv run {baseDir}/scripts/face_search.py
|
|
182
|
+
uv run {baseDir}/scripts/face_search.py quick-search "/path/to/face.jpg" "<dir>"
|
|
182
183
|
```
|
|
183
184
|
**命令返回后立刻关闭并删除该子会话。**
|
|
184
185
|
输出:
|
|
@@ -205,7 +206,7 @@ uv run {baseDir}/scripts/face_search.py -c {baseDir}/references/config.json quic
|
|
|
205
206
|
1. 从用户表述中提取**搜索目录**。若**未指定搜索目录**,先按「未指定搜索目录时」列出最近 3 个日期文件夹供选择(支持「更多」),得到 `<dir>` 后再继续。
|
|
206
207
|
2. 使用**子会话**执行分类(可能耗时较长):
|
|
207
208
|
```bash
|
|
208
|
-
uv run {baseDir}/scripts/face_search.py
|
|
209
|
+
uv run {baseDir}/scripts/face_search.py classify "<dir>"
|
|
209
210
|
```
|
|
210
211
|
**命令返回后立刻关闭并删除该子会话。**
|
|
211
212
|
输出(节选):
|
|
@@ -260,7 +261,7 @@ uv run {baseDir}/scripts/face_search.py -c {baseDir}/references/config.json clas
|
|
|
260
261
|
3. 提示确认:`⚠️🗑️ **确认删除用户 xxx?** · 此操作不可撤销`
|
|
261
262
|
- 用户确认 →
|
|
262
263
|
```bash
|
|
263
|
-
uv run {baseDir}/scripts/face_search.py
|
|
264
|
+
uv run {baseDir}/scripts/face_search.py delete "张三"
|
|
264
265
|
```
|
|
265
266
|
提示:`🗑️✨ **用户已删除** · xxx`
|
|
266
267
|
- 用户取消 → 提示:`↩️👋 **已取消删除**`
|
|
@@ -273,20 +274,20 @@ uv run {baseDir}/scripts/face_search.py -c {baseDir}/references/config.json clas
|
|
|
273
274
|
|
|
274
275
|
- **单用户 `search`**(指定 `--name`):
|
|
275
276
|
```bash
|
|
276
|
-
uv run {baseDir}/scripts/face_search.py
|
|
277
|
+
uv run {baseDir}/scripts/face_search.py pack --name "张三" --timeout 120
|
|
277
278
|
```
|
|
278
279
|
输出(节选):`{"success": true, "url": "https://...", "zip_path": "/tmp/.../search_results.zip", "dm": {...}, "dm_lines": [...]}`(有 `friendId` 时含 `dm` / `dm_lines`)
|
|
279
280
|
成功提示:`📦🔗 **下载链接已就绪** ✨` → `<url>` → 若有 `dm_lines`,再展示「下载链接私信状态」块。
|
|
280
281
|
|
|
281
282
|
- **`quick-search`(直接搜索)**:结果存于 `quick_search_result`,执行 **不带 `--name`** 的 `pack`(与仅含直接搜索结果时一致):
|
|
282
283
|
```bash
|
|
283
|
-
uv run {baseDir}/scripts/face_search.py
|
|
284
|
+
uv run {baseDir}/scripts/face_search.py pack --timeout 120
|
|
284
285
|
```
|
|
285
286
|
成功提示:`📦🔗 **下载链接已就绪** ✨` → `<url>`(通常**无**虾友私信,因无注册用户上下文)
|
|
286
287
|
|
|
287
288
|
- **一键 `classify`**(不指定 `--name`,**每位用户单独打包**):
|
|
288
289
|
```bash
|
|
289
|
-
uv run {baseDir}/scripts/face_search.py
|
|
290
|
+
uv run {baseDir}/scripts/face_search.py pack --timeout 120
|
|
290
291
|
```
|
|
291
292
|
输出示例(节选):
|
|
292
293
|
```json
|
|
@@ -319,7 +320,7 @@ uv run {baseDir}/scripts/face_search.py -c {baseDir}/references/config.json pack
|
|
|
319
320
|
1. 获取用户提供的**图片路径**(支持单张或多张)
|
|
320
321
|
2. 直接执行:
|
|
321
322
|
```bash
|
|
322
|
-
uv run {baseDir}/scripts/face_search.py
|
|
323
|
+
uv run {baseDir}/scripts/face_search.py upload-image "/path/to/a.jpg" "/path/to/b.jpg"
|
|
323
324
|
```
|
|
324
325
|
3. 单张时输出:`{"success": true/false, "folder_path": "/abs/path/to/images/YYYYMMDD", "file_path": "/abs/path/to/images/YYYYMMDD/a.jpg", "failed": []}`
|
|
325
326
|
4. 多张时输出:`{"success": true/false, "folder_path": "/abs/path/to/images/YYYYMMDD", "uploaded": [...], "failed": [...]}`
|
|
@@ -328,41 +329,40 @@ uv run {baseDir}/scripts/face_search.py -c {baseDir}/references/config.json uplo
|
|
|
328
329
|
## Example Workflow
|
|
329
330
|
|
|
330
331
|
- 用户:「把这张照片添加为张三」
|
|
331
|
-
1. `uv run {baseDir}/scripts/face_search.py
|
|
332
|
-
2. 不存在 → `uv run {baseDir}/scripts/face_search.py
|
|
332
|
+
1. `uv run {baseDir}/scripts/face_search.py check "张三"`
|
|
333
|
+
2. 不存在 → `uv run {baseDir}/scripts/face_search.py add "张三" "/path/to/photo.jpg"`
|
|
333
334
|
3. 提示:`🎉✨ **注册成功** · 张三`
|
|
334
335
|
|
|
335
336
|
- 用户:「用照片分类器在 /home/photos 下查找张三」
|
|
336
|
-
1. `uv run {baseDir}/scripts/face_search.py
|
|
337
|
-
2. 子会话执行 `uv run {baseDir}/scripts/face_search.py
|
|
337
|
+
1. `uv run {baseDir}/scripts/face_search.py check "张三"` → 存在
|
|
338
|
+
2. 子会话执行 `uv run {baseDir}/scripts/face_search.py search "张三" "/home/photos"`
|
|
338
339
|
3. 展示结果表格 → `pack --name "张三" --timeout 120` 生成下载链接(见 §6)→ 若有 `dm_lines`,展示下载链接私信状态
|
|
339
340
|
|
|
340
341
|
- 用户:「用这张照片在 /home/photos 里找找有没有类似的」
|
|
341
|
-
1. 子会话执行 `uv run {baseDir}/scripts/face_search.py
|
|
342
|
+
1. 子会话执行 `uv run {baseDir}/scripts/face_search.py quick-search "/tmp/face.jpg" "/home/photos"`
|
|
342
343
|
2. 成功 → 展示表格 → `pack --timeout 120`(不带 `--name`);失败 → 提示未检测到有效人脸
|
|
343
344
|
|
|
344
345
|
- 用户:「用照片分类器对 /home/album 进行分类」
|
|
345
|
-
1. 子会话执行 `uv run {baseDir}/scripts/face_search.py
|
|
346
|
+
1. 子会话执行 `uv run {baseDir}/scripts/face_search.py classify "/home/album"`
|
|
346
347
|
2. 展示各用户结果(及分类摘要 `dm_lines` 若有)→ `pack --timeout 120`(多用户各一链接,见 §6)→ 展示链接及 **`pack` 返回的 `dm_lines`**(下载链接私信状态)
|
|
347
348
|
|
|
348
349
|
- 用户:「删除照片分类器中李四的信息」
|
|
349
|
-
1. `uv run {baseDir}/scripts/face_search.py
|
|
350
|
-
2. 提示确认 → 用户确认 → `uv run {baseDir}/scripts/face_search.py
|
|
350
|
+
1. `uv run {baseDir}/scripts/face_search.py check "李四"` → 存在
|
|
351
|
+
2. 提示确认 → 用户确认 → `uv run {baseDir}/scripts/face_search.py delete "李四"`
|
|
351
352
|
3. 提示:`🗑️✨ **用户已删除** · 李四`
|
|
352
353
|
|
|
353
354
|
- 用户:「把这些图片放到待搜索目录」
|
|
354
|
-
1. `uv run {baseDir}/scripts/face_search.py
|
|
355
|
+
1. `uv run {baseDir}/scripts/face_search.py upload-image "/tmp/a.jpg" "/tmp/b.jpg"`
|
|
355
356
|
2. 全部成功后提示:`✅📤 **已放入待搜索目录** → <folder_path> ✨`
|
|
356
357
|
|
|
357
358
|
## Notes
|
|
358
359
|
|
|
359
|
-
-
|
|
360
|
+
- 配置与注册照片位于 `{dataRoot}/.image-classify/`;一般无需传 `-c`。首次运行若尚无 `.image-classify/config.json`,会按顺序从 `{dataRoot}/.references/` 或 `{baseDir}/references/` 迁移并删除旧目录。
|
|
360
361
|
- `search`、`quick-search` 和 `classify` 可能耗时较长(取决于搜索目录中的图片数量),应在**子会话**中执行;**结束后立即关闭并删除该子会话**,再将结果展示与 `pack` 放在**主会话**中完成。
|
|
361
362
|
- `images/` 目录用于存放待搜索图片;`upload-image` 内部会创建当天目录并串行逐张处理图片。
|
|
362
363
|
- 搜索过程中会在图片所在目录下创建 `.embedding` 隐藏目录缓存 embedding 结果,后续搜索同一目录会自动跳过已处理的图片。
|
|
363
364
|
- `pack` 与 `copy` 均依赖 `config.json` 中保存的搜索结果(`search_result` 或 `quick_search_result` 等),须先执行 `search`、`classify` 或 `quick-search`。默认流程只需 **`pack`**;`copy` 为可选,由用户另行提出时再执行。
|
|
364
|
-
-
|
|
365
|
-
- `pack`
|
|
366
|
-
- `config.json` 中的 `query_threshold`(默认 0.5)控制人脸检测置信度阈值,`search_similarity_threshold`(默认 0.3)控制搜索匹配的最低相似度。
|
|
365
|
+
- 已绑定 `friendId` 的用户:**`classify`** 会推送分类结果摘要;**`pack`** 会推送打包下载链接(两次私信、用途不同)。
|
|
366
|
+
- `pack` 的压缩文件在系统临时目录,上传完成后可忽略。默认上传超时 120 秒,大文件可通过 `--timeout` 增大。
|
|
367
367
|
- `{baseDir}` 指本 skill 根目录(如 `skills/image-classify`),调用时替换为实际绝对路径。
|
|
368
368
|
- `friendId`(虾友号)与可选 `friendLabel` 写在用户条目下;**注册时**通过 `add ... --friend-id` / `--friend-label` 写入。后续若需修改可编辑 `config.json`(本 skill 未单独提供改绑子命令)。旧键名仍兼容读取。
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "image-classify"
|
|
3
|
+
version = "1.0.5"
|
|
4
|
+
description = "Photo classifier by face embedding similarity"
|
|
5
|
+
requires-python = ">=3.10"
|
|
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
|
+
]
|
|
@@ -18,12 +18,230 @@ import sophnet_tools
|
|
|
18
18
|
# API配置
|
|
19
19
|
FACE_API_URL = "https://www.sophnet.com/api/open-apis/projects/detect_and_embed"
|
|
20
20
|
|
|
21
|
+
# 人脸检测与搜索阈值(不再写入 config.json)
|
|
22
|
+
QUERY_THRESHOLD = 0.5
|
|
23
|
+
SEARCH_SIMILARITY_THRESHOLD = 0.3
|
|
24
|
+
|
|
25
|
+
_CONFIG_STRIP_KEYS = ("query_threshold", "search_similarity_threshold")
|
|
26
|
+
_REGISTRATION_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
|
|
27
|
+
_DATA_DIR_NAME = ".image-classify"
|
|
28
|
+
_PREV_DATA_DIR_NAME = ".references"
|
|
29
|
+
_data_dir_ready = False
|
|
30
|
+
|
|
31
|
+
|
|
21
32
|
def convert_to_url(image_path, timeout=10):
|
|
22
33
|
return sophnet_tools.upload_oss(image_path, timeout)
|
|
23
34
|
|
|
35
|
+
|
|
36
|
+
def get_skill_src_dir():
|
|
37
|
+
"""skill 源码目录(含 scripts、SKILL.md)。"""
|
|
38
|
+
return os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_data_root():
|
|
42
|
+
"""与 images 同级的工作区根目录(通常为 sophclaw-skills)。"""
|
|
43
|
+
return os.path.abspath(os.path.join(get_skill_src_dir(), "..", ".."))
|
|
44
|
+
|
|
45
|
+
|
|
24
46
|
def get_images_path():
|
|
25
|
-
|
|
26
|
-
|
|
47
|
+
return os.path.join(get_data_root(), "images")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_image_classify_dir():
|
|
51
|
+
return os.path.join(get_data_root(), _DATA_DIR_NAME)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_config_path():
|
|
55
|
+
return os.path.join(get_image_classify_dir(), "config.json")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_prev_data_dir():
|
|
59
|
+
"""上一版数据目录名(.references),仅用于迁移。"""
|
|
60
|
+
return os.path.join(get_data_root(), _PREV_DATA_DIR_NAME)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_legacy_skill_references_dir():
|
|
64
|
+
"""更早期 skill 包内 references 目录(src/references)。"""
|
|
65
|
+
return os.path.join(get_skill_src_dir(), "references")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _sanitize_config_data(data):
|
|
69
|
+
if not isinstance(data, dict):
|
|
70
|
+
return {"users": []}
|
|
71
|
+
out = {k: v for k, v in data.items() if k not in _CONFIG_STRIP_KEYS}
|
|
72
|
+
if "users" not in out:
|
|
73
|
+
out["users"] = []
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def save_config(config_path, data):
|
|
78
|
+
path = resolve_config_path(config_path)
|
|
79
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
80
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
81
|
+
json.dump(_sanitize_config_data(data), f, ensure_ascii=False, indent=2)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def resolve_config_path(config_path=None):
|
|
85
|
+
"""解析配置文件路径;兼容旧版 config 路径。"""
|
|
86
|
+
ensure_image_classify_ready()
|
|
87
|
+
if config_path is None:
|
|
88
|
+
return get_config_path()
|
|
89
|
+
norm = config_path.replace("\\", "/")
|
|
90
|
+
legacy_names = (
|
|
91
|
+
"references/config.json",
|
|
92
|
+
".references/config.json",
|
|
93
|
+
".image-classify/config.json",
|
|
94
|
+
)
|
|
95
|
+
if norm in legacy_names:
|
|
96
|
+
return get_config_path()
|
|
97
|
+
if any(norm.endswith(f"/{name}") for name in legacy_names):
|
|
98
|
+
return get_config_path()
|
|
99
|
+
legacy = os.path.join(get_legacy_skill_references_dir(), "config.json").replace("\\", "/")
|
|
100
|
+
if os.path.abspath(config_path).replace("\\", "/") == legacy:
|
|
101
|
+
return get_config_path()
|
|
102
|
+
return os.path.abspath(config_path)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _unique_path(directory, basename):
|
|
106
|
+
stem, suffix = os.path.splitext(basename)
|
|
107
|
+
target = os.path.join(directory, basename)
|
|
108
|
+
counter = 1
|
|
109
|
+
while os.path.exists(target):
|
|
110
|
+
target = os.path.join(directory, f"{stem}_{counter}{suffix}")
|
|
111
|
+
counter += 1
|
|
112
|
+
return target
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def store_registration_image(image_path):
|
|
116
|
+
"""将注册照复制到 .image-classify 目录,返回最终绝对路径。"""
|
|
117
|
+
data_dir = get_image_classify_dir()
|
|
118
|
+
os.makedirs(data_dir, exist_ok=True)
|
|
119
|
+
src = os.path.realpath(image_path)
|
|
120
|
+
if not os.path.isfile(src):
|
|
121
|
+
return src
|
|
122
|
+
data_root = os.path.realpath(data_dir)
|
|
123
|
+
try:
|
|
124
|
+
if os.path.commonpath([src, data_root]) == data_root:
|
|
125
|
+
return src
|
|
126
|
+
except ValueError:
|
|
127
|
+
pass
|
|
128
|
+
target = _unique_path(data_dir, os.path.basename(src))
|
|
129
|
+
shutil.copy2(src, target)
|
|
130
|
+
return os.path.realpath(target)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _copy_photo_to_references(file_path, legacy_dir, new_dir):
|
|
134
|
+
if not file_path:
|
|
135
|
+
return None
|
|
136
|
+
candidates = []
|
|
137
|
+
if os.path.isabs(file_path):
|
|
138
|
+
candidates.append(file_path)
|
|
139
|
+
else:
|
|
140
|
+
candidates.append(os.path.join(legacy_dir, file_path))
|
|
141
|
+
candidates.append(os.path.join(new_dir, file_path))
|
|
142
|
+
src = None
|
|
143
|
+
for path in candidates:
|
|
144
|
+
if path and os.path.isfile(path):
|
|
145
|
+
src = os.path.realpath(path)
|
|
146
|
+
break
|
|
147
|
+
if not src:
|
|
148
|
+
return None
|
|
149
|
+
try:
|
|
150
|
+
if os.path.commonpath([src, os.path.realpath(new_dir)]) == os.path.realpath(new_dir):
|
|
151
|
+
return src
|
|
152
|
+
except ValueError:
|
|
153
|
+
pass
|
|
154
|
+
target = _unique_path(new_dir, os.path.basename(src))
|
|
155
|
+
shutil.copy2(src, target)
|
|
156
|
+
return os.path.realpath(target)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _migrate_user_photos(data, legacy_dir, new_dir):
|
|
160
|
+
for user in data.get("users", []):
|
|
161
|
+
for info in user.get("info", []):
|
|
162
|
+
fp = info.get("file_path", "")
|
|
163
|
+
if not fp:
|
|
164
|
+
continue
|
|
165
|
+
new_fp = _copy_photo_to_references(fp, legacy_dir, new_dir)
|
|
166
|
+
if new_fp:
|
|
167
|
+
info["file_path"] = new_fp
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _copy_legacy_reference_assets(legacy_dir, new_dir, data):
|
|
171
|
+
referenced = set()
|
|
172
|
+
for user in data.get("users", []):
|
|
173
|
+
for info in user.get("info", []):
|
|
174
|
+
fp = info.get("file_path")
|
|
175
|
+
if fp:
|
|
176
|
+
referenced.add(os.path.realpath(fp))
|
|
177
|
+
if not os.path.isdir(legacy_dir):
|
|
178
|
+
return
|
|
179
|
+
for name in os.listdir(legacy_dir):
|
|
180
|
+
if name == "config.json":
|
|
181
|
+
continue
|
|
182
|
+
src = os.path.join(legacy_dir, name)
|
|
183
|
+
if not os.path.isfile(src):
|
|
184
|
+
continue
|
|
185
|
+
if os.path.splitext(name)[1].lower() not in _REGISTRATION_IMAGE_EXTS:
|
|
186
|
+
continue
|
|
187
|
+
dst = os.path.join(new_dir, name)
|
|
188
|
+
if os.path.exists(dst):
|
|
189
|
+
continue
|
|
190
|
+
shutil.copy2(src, dst)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _cleanup_data_dir(old_dir):
|
|
194
|
+
"""迁移成功后删除旧数据目录。"""
|
|
195
|
+
if not os.path.isdir(old_dir):
|
|
196
|
+
return
|
|
197
|
+
try:
|
|
198
|
+
shutil.rmtree(old_dir)
|
|
199
|
+
print(f"[迁移] 已删除旧目录 {old_dir}", file=sys.stderr)
|
|
200
|
+
except OSError as exc:
|
|
201
|
+
print(f"[迁移] 无法删除旧目录 {old_dir}: {exc}", file=sys.stderr)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _migrate_data_between_dirs(source_dir, target_dir):
|
|
205
|
+
source_config = os.path.join(source_dir, "config.json")
|
|
206
|
+
if not os.path.isfile(source_config):
|
|
207
|
+
return False
|
|
208
|
+
with open(source_config, "r", encoding="utf-8") as f:
|
|
209
|
+
data = _sanitize_config_data(json.load(f))
|
|
210
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
211
|
+
_migrate_user_photos(data, source_dir, target_dir)
|
|
212
|
+
_copy_legacy_reference_assets(source_dir, target_dir, data)
|
|
213
|
+
save_config(os.path.join(target_dir, "config.json"), data)
|
|
214
|
+
print(f"[迁移] 已从 {source_dir} 迁移到 {target_dir}", file=sys.stderr)
|
|
215
|
+
return True
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def ensure_image_classify_ready():
|
|
219
|
+
"""首次运行时创建 .image-classify,并按需从 .references 或 skill/references 迁移。"""
|
|
220
|
+
global _data_dir_ready
|
|
221
|
+
if _data_dir_ready:
|
|
222
|
+
return
|
|
223
|
+
_data_dir_ready = True
|
|
224
|
+
|
|
225
|
+
new_dir = get_image_classify_dir()
|
|
226
|
+
new_config = get_config_path()
|
|
227
|
+
os.makedirs(new_dir, exist_ok=True)
|
|
228
|
+
if os.path.isfile(new_config):
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
prev_config = os.path.join(get_prev_data_dir(), "config.json")
|
|
232
|
+
if os.path.isfile(prev_config):
|
|
233
|
+
if _migrate_data_between_dirs(get_prev_data_dir(), new_dir):
|
|
234
|
+
_cleanup_data_dir(get_prev_data_dir())
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
legacy_dir = get_legacy_skill_references_dir()
|
|
238
|
+
legacy_config = os.path.join(legacy_dir, "config.json")
|
|
239
|
+
if os.path.isfile(legacy_config):
|
|
240
|
+
if _migrate_data_between_dirs(legacy_dir, new_dir):
|
|
241
|
+
_cleanup_data_dir(legacy_dir)
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
save_config(new_config, {"users": []})
|
|
27
245
|
|
|
28
246
|
def list_date_folders():
|
|
29
247
|
"""列出待搜索目录 images 下所有 YYYYMMDD 子目录,按日期从新到旧排序。
|
|
@@ -244,17 +462,14 @@ def get_baseface_embedding(image_path, det_thr=0.7):
|
|
|
244
462
|
print(f"错误: {e}", file=sys.stderr)
|
|
245
463
|
return None
|
|
246
464
|
|
|
247
|
-
def get_config(config_path=
|
|
248
|
-
"""
|
|
465
|
+
def get_config(config_path=None):
|
|
466
|
+
"""从 .image-classify/config.json 读取用户与搜索结果。"""
|
|
467
|
+
path = resolve_config_path(config_path)
|
|
249
468
|
try:
|
|
250
|
-
with open(
|
|
251
|
-
|
|
252
|
-
return config
|
|
469
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
470
|
+
return _sanitize_config_data(json.load(f))
|
|
253
471
|
except FileNotFoundError:
|
|
254
|
-
return {
|
|
255
|
-
"query_threshold": 0.7,
|
|
256
|
-
"search_similarity_threshold": 0.3
|
|
257
|
-
}
|
|
472
|
+
return {"users": []}
|
|
258
473
|
|
|
259
474
|
|
|
260
475
|
def _user_friend_id(user):
|
|
@@ -276,7 +491,7 @@ def _user_friend_label(user):
|
|
|
276
491
|
return v
|
|
277
492
|
return user.get("xia_you_label")
|
|
278
493
|
|
|
279
|
-
def name_in_config(name, config_path=
|
|
494
|
+
def name_in_config(name, config_path=None):
|
|
280
495
|
"""检查名字是否在配置文件的用户列表中"""
|
|
281
496
|
basic_config = get_config(config_path)
|
|
282
497
|
user_list = basic_config.get("users", [])
|
|
@@ -285,7 +500,7 @@ def name_in_config(name, config_path='references/config.json'):
|
|
|
285
500
|
return True
|
|
286
501
|
return False
|
|
287
502
|
|
|
288
|
-
def replace_name_config(ost_name, new_name, config_path=
|
|
503
|
+
def replace_name_config(ost_name, new_name, config_path=None):
|
|
289
504
|
"""替换配置文件中用户列表中的名字"""
|
|
290
505
|
basic_config = get_config(config_path)
|
|
291
506
|
user_list = basic_config.get("users", [])
|
|
@@ -294,11 +509,10 @@ def replace_name_config(ost_name, new_name, config_path='references/config.json'
|
|
|
294
509
|
user["name"] = new_name
|
|
295
510
|
break
|
|
296
511
|
basic_config["users"] = user_list
|
|
297
|
-
|
|
298
|
-
json.dump(basic_config, f, indent=4, ensure_ascii=False)
|
|
512
|
+
save_config(config_path, basic_config)
|
|
299
513
|
|
|
300
514
|
|
|
301
|
-
def delete_user_config(ost_name, config_path=
|
|
515
|
+
def delete_user_config(ost_name, config_path=None):
|
|
302
516
|
"""删除配置文件中用户列表中的用户信息"""
|
|
303
517
|
basic_config = get_config(config_path)
|
|
304
518
|
user_list = basic_config.get("users", [])
|
|
@@ -307,24 +521,24 @@ def delete_user_config(ost_name, config_path='references/config.json'):
|
|
|
307
521
|
user_list.remove(user)
|
|
308
522
|
break
|
|
309
523
|
basic_config["users"] = user_list
|
|
310
|
-
|
|
311
|
-
json.dump(basic_config, f, indent=4, ensure_ascii=False)
|
|
524
|
+
save_config(config_path, basic_config)
|
|
312
525
|
|
|
313
526
|
def add_user_config(
|
|
314
527
|
name,
|
|
315
528
|
image_path,
|
|
316
|
-
config_path=
|
|
529
|
+
config_path=None,
|
|
317
530
|
friend_id=None,
|
|
318
531
|
friend_label=None,
|
|
319
532
|
):
|
|
320
533
|
"""添加用户到 config。friendId 为可选好友 userId(正整数);friendLabel 为可选展示名。"""
|
|
321
534
|
basic_config = get_config(config_path)
|
|
322
535
|
user_list = basic_config.get("users", [])
|
|
323
|
-
|
|
536
|
+
stored_path = store_registration_image(image_path)
|
|
537
|
+
face_embedding = get_baseface_embedding(stored_path, det_thr=QUERY_THRESHOLD)
|
|
324
538
|
if face_embedding is None:
|
|
325
539
|
return False, "未检测到有效的人脸信息"
|
|
326
540
|
|
|
327
|
-
abs_image_path = os.path.realpath(
|
|
541
|
+
abs_image_path = os.path.realpath(stored_path)
|
|
328
542
|
entry = {"name": name, "info": [{"file_path": abs_image_path, "embedding": face_embedding}]}
|
|
329
543
|
if friend_id is not None:
|
|
330
544
|
entry["friendId"] = int(friend_id)
|
|
@@ -332,21 +546,21 @@ def add_user_config(
|
|
|
332
546
|
entry["friendLabel"] = str(friend_label).strip()
|
|
333
547
|
user_list.append(entry)
|
|
334
548
|
basic_config["users"] = user_list
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
549
|
+
save_config(config_path, basic_config)
|
|
550
|
+
|
|
338
551
|
return True, "添加成功"
|
|
339
552
|
|
|
340
553
|
|
|
341
|
-
def replace_user_embeding_config(name, new_image_path, config_path=
|
|
554
|
+
def replace_user_embeding_config(name, new_image_path, config_path=None):
|
|
342
555
|
"""根据用户名替换其所有照片和embedding信息,并删除原始照片"""
|
|
343
556
|
basic_config = get_config(config_path)
|
|
344
557
|
user_list = basic_config.get("users", [])
|
|
345
|
-
|
|
558
|
+
stored_path = store_registration_image(new_image_path)
|
|
559
|
+
face_embedding = get_baseface_embedding(stored_path, det_thr=QUERY_THRESHOLD)
|
|
346
560
|
if face_embedding is None:
|
|
347
561
|
return False, "未检测到有效的人脸信息"
|
|
348
562
|
|
|
349
|
-
abs_new_path = os.path.realpath(
|
|
563
|
+
abs_new_path = os.path.realpath(stored_path)
|
|
350
564
|
for user in user_list:
|
|
351
565
|
if user.get("name") == name:
|
|
352
566
|
for info in user.get("info", []):
|
|
@@ -356,28 +570,27 @@ def replace_user_embeding_config(name, new_image_path, config_path='references/c
|
|
|
356
570
|
user["info"] = [{"file_path": abs_new_path, "embedding": face_embedding}]
|
|
357
571
|
break
|
|
358
572
|
basic_config["users"] = user_list
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
573
|
+
save_config(config_path, basic_config)
|
|
574
|
+
|
|
362
575
|
return True, "替换成功"
|
|
363
576
|
|
|
364
|
-
def append_user_embeding_config(name, image_path, config_path=
|
|
577
|
+
def append_user_embeding_config(name, image_path, config_path=None):
|
|
365
578
|
"""新增用户embedding信息"""
|
|
366
579
|
basic_config = get_config(config_path)
|
|
367
580
|
user_list = basic_config.get("users", [])
|
|
368
|
-
|
|
581
|
+
stored_path = store_registration_image(image_path)
|
|
582
|
+
face_embedding = get_baseface_embedding(stored_path, det_thr=QUERY_THRESHOLD)
|
|
369
583
|
if face_embedding is None:
|
|
370
584
|
return False, "未检测到有效的人脸信息"
|
|
371
585
|
|
|
372
586
|
for user in user_list:
|
|
373
587
|
if user.get("name") == name:
|
|
374
|
-
abs_image_path = os.path.realpath(
|
|
588
|
+
abs_image_path = os.path.realpath(stored_path)
|
|
375
589
|
user.get("info", []).append({"file_path": abs_image_path, "embedding": face_embedding})
|
|
376
590
|
break
|
|
377
591
|
basic_config["users"] = user_list
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
592
|
+
save_config(config_path, basic_config)
|
|
593
|
+
|
|
381
594
|
return True, "添加成功"
|
|
382
595
|
|
|
383
596
|
def save_embeddings(image_path, faces, json_path):
|
|
@@ -450,7 +663,7 @@ def cosine_similarity(vec1, vec2):
|
|
|
450
663
|
return 0
|
|
451
664
|
return dot_product / (norm1 * norm2)
|
|
452
665
|
|
|
453
|
-
def search_similar_faces(name, search_path, config_path=
|
|
666
|
+
def search_similar_faces(name, search_path, config_path=None):
|
|
454
667
|
"""搜索相似人脸"""
|
|
455
668
|
basic_config = get_config(config_path)
|
|
456
669
|
user_list = basic_config.get("users", [])
|
|
@@ -463,7 +676,7 @@ def search_similar_faces(name, search_path, config_path='references/config.json'
|
|
|
463
676
|
if embedding:
|
|
464
677
|
embedding_list.append(embedding)
|
|
465
678
|
|
|
466
|
-
json_list = embeddings_from_imagepath(search_path, det_thr=
|
|
679
|
+
json_list = embeddings_from_imagepath(search_path, det_thr=QUERY_THRESHOLD)
|
|
467
680
|
search_embeddings = get_searchface_embeddings_from_json(json_list)
|
|
468
681
|
|
|
469
682
|
|
|
@@ -472,7 +685,7 @@ def search_similar_faces(name, search_path, config_path='references/config.json'
|
|
|
472
685
|
for image_name, embeddings in search_embeddings.items():
|
|
473
686
|
for idx, embedding in enumerate(embeddings):
|
|
474
687
|
similarity = cosine_similarity(face_embedding, embedding)
|
|
475
|
-
if similarity >=
|
|
688
|
+
if similarity >= SEARCH_SIMILARITY_THRESHOLD:
|
|
476
689
|
existing = None
|
|
477
690
|
for result in results:
|
|
478
691
|
if result.get("image_path") == image_name:
|
|
@@ -491,31 +704,28 @@ def search_similar_faces(name, search_path, config_path='references/config.json'
|
|
|
491
704
|
for user in user_list:
|
|
492
705
|
if user.get("name") == name:
|
|
493
706
|
user["search_result"] = results
|
|
494
|
-
|
|
495
|
-
json.dump(basic_config, f, ensure_ascii=False, indent=2)
|
|
707
|
+
save_config(config_path, basic_config)
|
|
496
708
|
return results
|
|
497
709
|
|
|
498
710
|
|
|
499
|
-
def quick_search(image_path, search_path, config_path=
|
|
711
|
+
def quick_search(image_path, search_path, config_path=None):
|
|
500
712
|
"""直接用一张照片搜索相似人脸,结果缓存到 config.json 的 quick_search_result 字段,
|
|
501
713
|
供后续 copy/pack 命令使用。
|
|
502
714
|
"""
|
|
503
715
|
basic_config = get_config(config_path)
|
|
504
|
-
det_thr = basic_config.get("query_threshold", 0.7)
|
|
505
|
-
search_threshold = basic_config.get("search_similarity_threshold", 0.3)
|
|
506
716
|
|
|
507
|
-
face_embedding = get_baseface_embedding(image_path, det_thr=
|
|
717
|
+
face_embedding = get_baseface_embedding(image_path, det_thr=QUERY_THRESHOLD)
|
|
508
718
|
if face_embedding is None:
|
|
509
719
|
return None, "照片中未发现有效的人脸信息"
|
|
510
720
|
|
|
511
|
-
json_list = embeddings_from_imagepath(search_path, det_thr=
|
|
721
|
+
json_list = embeddings_from_imagepath(search_path, det_thr=QUERY_THRESHOLD)
|
|
512
722
|
search_embeddings = get_searchface_embeddings_from_json(json_list)
|
|
513
723
|
|
|
514
724
|
results = []
|
|
515
725
|
for img_name, embeddings in search_embeddings.items():
|
|
516
726
|
for idx, embedding in enumerate(embeddings):
|
|
517
727
|
similarity = cosine_similarity(face_embedding, embedding)
|
|
518
|
-
if similarity >=
|
|
728
|
+
if similarity >= SEARCH_SIMILARITY_THRESHOLD:
|
|
519
729
|
existing = None
|
|
520
730
|
for result in results:
|
|
521
731
|
if result.get("image_path") == img_name:
|
|
@@ -534,13 +744,12 @@ def quick_search(image_path, search_path, config_path='references/config.json'):
|
|
|
534
744
|
results.sort(key=lambda x: x["similarity"], reverse=True)
|
|
535
745
|
|
|
536
746
|
basic_config["quick_search_result"] = results
|
|
537
|
-
|
|
538
|
-
json.dump(basic_config, f, ensure_ascii=False, indent=2)
|
|
747
|
+
save_config(config_path, basic_config)
|
|
539
748
|
|
|
540
749
|
return results, "搜索完成"
|
|
541
750
|
|
|
542
751
|
|
|
543
|
-
def classify_all_users(search_path, config_path=
|
|
752
|
+
def classify_all_users(search_path, config_path=None):
|
|
544
753
|
"""一键分类:遍历所有注册用户,搜索所有图片并返回每个用户的匹配结果"""
|
|
545
754
|
basic_config = get_config(config_path)
|
|
546
755
|
user_list = basic_config.get("users", [])
|
|
@@ -548,9 +757,8 @@ def classify_all_users(search_path, config_path='references/config.json'):
|
|
|
548
757
|
if not user_list:
|
|
549
758
|
return {}
|
|
550
759
|
|
|
551
|
-
json_list = embeddings_from_imagepath(search_path, det_thr=
|
|
760
|
+
json_list = embeddings_from_imagepath(search_path, det_thr=QUERY_THRESHOLD)
|
|
552
761
|
search_embeddings = get_searchface_embeddings_from_json(json_list)
|
|
553
|
-
search_threshold = basic_config.get("search_similarity_threshold", 0.3)
|
|
554
762
|
|
|
555
763
|
all_results = {}
|
|
556
764
|
for user in user_list:
|
|
@@ -569,7 +777,7 @@ def classify_all_users(search_path, config_path='references/config.json'):
|
|
|
569
777
|
for image_name, embeddings in search_embeddings.items():
|
|
570
778
|
for idx, embedding in enumerate(embeddings):
|
|
571
779
|
similarity = cosine_similarity(face_embedding, embedding)
|
|
572
|
-
if similarity >=
|
|
780
|
+
if similarity >= SEARCH_SIMILARITY_THRESHOLD:
|
|
573
781
|
existing = None
|
|
574
782
|
for result in results:
|
|
575
783
|
if result.get("image_path") == image_name:
|
|
@@ -589,8 +797,7 @@ def classify_all_users(search_path, config_path='references/config.json'):
|
|
|
589
797
|
user["search_result"] = results
|
|
590
798
|
all_results[name] = results
|
|
591
799
|
|
|
592
|
-
|
|
593
|
-
json.dump(basic_config, f, ensure_ascii=False, indent=2)
|
|
800
|
+
save_config(config_path, basic_config)
|
|
594
801
|
|
|
595
802
|
return all_results
|
|
596
803
|
|
|
@@ -977,8 +1184,12 @@ def _json_output(data):
|
|
|
977
1184
|
|
|
978
1185
|
def main():
|
|
979
1186
|
parser = argparse.ArgumentParser(description="📸 照片分类器 CLI")
|
|
980
|
-
parser.add_argument(
|
|
981
|
-
|
|
1187
|
+
parser.add_argument(
|
|
1188
|
+
"-c",
|
|
1189
|
+
"--config",
|
|
1190
|
+
default=None,
|
|
1191
|
+
help="配置文件路径 (默认: 与 images 同级的 .image-classify/config.json)",
|
|
1192
|
+
)
|
|
982
1193
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
983
1194
|
|
|
984
1195
|
# check — 检查用户名是否存在
|
|
@@ -1068,7 +1279,7 @@ def main():
|
|
|
1068
1279
|
sp.add_argument("file_paths", nargs="+", help="一个或多个图片文件路径")
|
|
1069
1280
|
|
|
1070
1281
|
args = parser.parse_args()
|
|
1071
|
-
cfg = args.config
|
|
1282
|
+
cfg = resolve_config_path(args.config)
|
|
1072
1283
|
|
|
1073
1284
|
if args.command == "check":
|
|
1074
1285
|
exists = name_in_config(args.name, cfg)
|
|
@@ -1221,6 +1432,8 @@ def main():
|
|
|
1221
1432
|
preview_n = 3
|
|
1222
1433
|
_json_output({
|
|
1223
1434
|
"images_base": get_images_path(),
|
|
1435
|
+
"image_classify_dir": get_image_classify_dir(),
|
|
1436
|
+
"config_path": get_config_path(),
|
|
1224
1437
|
"folders": folders,
|
|
1225
1438
|
"total": len(folders),
|
|
1226
1439
|
"preview_n": preview_n,
|