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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sophhub",
3
- "version": "0.4.24",
3
+ "version": "0.4.25",
4
4
  "description": "SophHub CLI - Manage and download AI Agent skills and agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,12 +1,19 @@
1
1
  {
2
2
  "name": "agent-install",
3
- "version": "0.1.5",
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-15"
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.5
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**:读 workspace 内 `.config.json` 的 `skills`,对 **`auto_install: true`** 的条目:
194
+ **Skill 重新安装**:读 workspace 内 `.config.json` 的 `skills`,对 **`auto_install: true`** 的条目**依次**下载并**直接替换** `{workspace}/skills/{skill_name}` 目录(先删旧目录再写入新包;本地对该 skill 的改动会被覆盖):
195
195
 
196
196
  ```bash
197
- mkdir -p "{workspace}/skills"
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 工作目录;多条**依次**下载。必要时加 `--type builtin` / `--type store`。
201
+ `{workspace}` 为本 Agent 工作目录。新装与升级/重置均须执行本步。
205
202
 
206
203
  **输出模版**
207
204
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent-install"
3
- version = "0.1.5"
3
+ version = "0.1.6"
4
4
  description = "通用 Agent 安装与升级"
5
5
  requires-python = ">=3.10"
6
6
  dependencies = []
@@ -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.4",
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-18"
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
- - 配置文件:`{baseDir}/references/config.json`
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
- - **配置文件**:`{baseDir}/references/config.json`,存储所有注册用户的名称、照片路径、人脸 embedding 向量,以及可选 **DM 绑定**:`friendId`(好友 userId)、`friendLabel`(可选展示名)。旧字段 `xia_you_hao` / `xia_you_label` 仍可被脚本读取。
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` 可选,指定配置文件路径,默认为 `references/config.json`(相对于执行目录)。建议始终传入绝对路径 `{baseDir}/references/config.json`。
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
- 待搜索根目录由脚本内 `get_images_path()` 解析(通常为仓库上级 `images`)。其下按日期命名的子目录(`YYYYMMDD`)为可选搜索范围。
67
+ 待搜索根目录为 `{dataRoot}/images`(与 `.image-classify` 同级,由脚本自动解析)。其下按日期命名的子目录(`YYYYMMDD`)为可选搜索范围。
67
68
 
68
69
  若用户**未说明**搜索目录(或只说「默认目录」「最近上传的」等),**不要猜测路径**,先执行:
69
70
 
70
71
  ```bash
71
- uv run {baseDir}/scripts/face_search.py -c {baseDir}/references/config.json list-date-folders
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**(虾友即 DM 对象;**23725** 即 `friendId`)。解析规则:**xxx** → `--friend-label`;**23725** → `--friend-id`(正整数,与 `send_dm_message.py` 的 `--user-id` / 接口 `userId` 一致)。可只填 `friendId`、不填展示名。
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 -c {baseDir}/references/config.json check "张三"
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 -c {baseDir}/references/config.json add "张三" "/path/to/photo.jpg"
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 -c {baseDir}/references/config.json add "张三" "/path/to/photo.jpg" --friend-label "小李" --friend-id 23725
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` 展示照片:`![image](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 -c {baseDir}/references/config.json replace "张三" "/path/to/new.jpg"
128
+ uv run {baseDir}/scripts/face_search.py replace "张三" "/path/to/new.jpg"
128
129
  ```
129
130
  输出包含 `image_url` 字段。
130
131
  - 成功:`✅🔄 **照片已更新** · xxx`,使用 `image_url` 展示照片:`![image](image_url)`
131
132
  - 失败:`❌🙈 照片中未发现有效的人脸信息,更新失败!`
132
133
  - **重新命名**:
133
134
  ```bash
134
- uv run {baseDir}/scripts/face_search.py -c {baseDir}/references/config.json rename "张三" "李三"
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 -c {baseDir}/references/config.json append "张三" "/path/to/extra.jpg"
140
+ uv run {baseDir}/scripts/face_search.py append "张三" "/path/to/extra.jpg"
140
141
  ```
141
142
  输出包含 `image_url` 字段。
142
143
  - 成功:`✅➕ **新照片已追加** · xxx`,使用 `image_url` 展示照片:`![image](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 -c {baseDir}/references/config.json search "张三" "<dir>"
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 -c {baseDir}/references/config.json quick-search "/path/to/face.jpg" "<dir>"
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 -c {baseDir}/references/config.json classify "<dir>"
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 -c {baseDir}/references/config.json delete "张三"
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 -c {baseDir}/references/config.json pack --name "张三" --timeout 120
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 -c {baseDir}/references/config.json pack --timeout 120
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 -c {baseDir}/references/config.json pack --timeout 120
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 -c {baseDir}/references/config.json upload-image "/path/to/a.jpg" "/path/to/b.jpg"
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 -c {baseDir}/references/config.json check "张三"`
332
- 2. 不存在 → `uv run {baseDir}/scripts/face_search.py -c {baseDir}/references/config.json add "张三" "/path/to/photo.jpg"`
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 -c {baseDir}/references/config.json check "张三"` → 存在
337
- 2. 子会话执行 `uv run {baseDir}/scripts/face_search.py -c {baseDir}/references/config.json search "张三" "/home/photos"`
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 -c {baseDir}/references/config.json quick-search "/tmp/face.jpg" "/home/photos"`
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 -c {baseDir}/references/config.json classify "/home/album"`
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 -c {baseDir}/references/config.json check "李四"` → 存在
350
- 2. 提示确认 → 用户确认 → `uv run {baseDir}/scripts/face_search.py -c {baseDir}/references/config.json delete "李四"`
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 -c {baseDir}/references/config.json upload-image "/tmp/a.jpg" "/tmp/b.jpg"`
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
- - 建议始终通过 `-c` 传入 `{baseDir}/references/config.json` 的绝对路径,避免相对路径导致找不到配置文件。
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
- - **`classify`** 会向 `friendId` 发**分类结果摘要**;**`pack`** 会向 `friendId` 发**打包下载链接**(两次私信、用途不同);若只需其一,可后续再改脚本或配置(当前实现为两者都发)。
365
- - `pack` 的压缩文件存放在系统临时目录中,上传完成后可忽略清理。上传超时默认 120 秒,文件特别大时可通过 `--timeout` 增大。
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
- current_file_dir = os.path.dirname(os.path.abspath(__file__))
26
- return os.path.abspath(os.path.join(current_file_dir, "..", "..", "..", "images"))
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='references/config.json'):
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(config_path, 'r', encoding='utf-8') as f:
251
- config = json.load(f)
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='references/config.json'):
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='references/config.json'):
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
- with open(config_path, 'w', encoding='utf-8') as f:
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='references/config.json'):
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
- with open(config_path, 'w', encoding='utf-8') as f:
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="references/config.json",
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
- face_embedding = get_baseface_embedding(image_path, det_thr=basic_config.get("query_threshold", 0.7))
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(image_path)
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
- with open(config_path, 'w', encoding='utf-8') as f:
336
- json.dump(basic_config, f, indent=4, ensure_ascii=False)
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='references/config.json'):
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
- face_embedding = get_baseface_embedding(new_image_path, det_thr=basic_config.get("query_threshold", 0.7))
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(new_image_path)
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
- with open(config_path, 'w', encoding='utf-8') as f:
360
- json.dump(basic_config, f, indent=4, ensure_ascii=False)
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='references/config.json'):
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
- face_embedding = get_baseface_embedding(image_path, det_thr=basic_config.get("query_threshold", 0.7))
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(image_path)
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
- with open(config_path, 'w', encoding='utf-8') as f:
379
- json.dump(basic_config, f, indent=4, ensure_ascii=False)
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='references/config.json'):
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=basic_config.get("query_threshold", 0.7))
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 >= basic_config.get("search_similarity_threshold", 0.3):
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
- with open(config_path, 'w', encoding='utf-8') as f:
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='references/config.json'):
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=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=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 >= search_threshold:
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
- with open(config_path, 'w', encoding='utf-8') as f:
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='references/config.json'):
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=basic_config.get("query_threshold", 0.7))
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 >= search_threshold:
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
- with open(config_path, 'w', encoding='utf-8') as f:
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("-c", "--config", default="references/config.json",
981
- help="配置文件路径 (默认: references/config.json)")
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,
@@ -1,4 +0,0 @@
1
- {
2
- "query_threshold": 0.5,
3
- "search_similarity_threshold": 0.3
4
- }