picren-ai 0.1.0__tar.gz
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.
- picren_ai-0.1.0/LICENSE +21 -0
- picren_ai-0.1.0/PKG-INFO +105 -0
- picren_ai-0.1.0/README.md +95 -0
- picren_ai-0.1.0/picren/__init__.py +3 -0
- picren_ai-0.1.0/picren/executor.py +73 -0
- picren_ai-0.1.0/picren/main.py +251 -0
- picren_ai-0.1.0/picren/pipeline.py +140 -0
- picren_ai-0.1.0/picren/reader.py +120 -0
- picren_ai-0.1.0/picren/rename_rules.py +62 -0
- picren_ai-0.1.0/picren/undo.py +114 -0
- picren_ai-0.1.0/picren/vision.py +147 -0
- picren_ai-0.1.0/picren/webui.py +255 -0
- picren_ai-0.1.0/picren_ai.egg-info/PKG-INFO +105 -0
- picren_ai-0.1.0/picren_ai.egg-info/SOURCES.txt +22 -0
- picren_ai-0.1.0/picren_ai.egg-info/dependency_links.txt +1 -0
- picren_ai-0.1.0/picren_ai.egg-info/entry_points.txt +2 -0
- picren_ai-0.1.0/picren_ai.egg-info/top_level.txt +1 -0
- picren_ai-0.1.0/pyproject.toml +26 -0
- picren_ai-0.1.0/setup.cfg +4 -0
- picren_ai-0.1.0/tests/test_main.py +507 -0
- picren_ai-0.1.0/tests/test_reader.py +173 -0
- picren_ai-0.1.0/tests/test_rules.py +161 -0
- picren_ai-0.1.0/tests/test_undo.py +223 -0
- picren_ai-0.1.0/tests/test_vision.py +253 -0
picren_ai-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 anyuer678
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
picren_ai-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: picren-ai
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 图片批量 AI 重命名/整理器:AI 看图、改名、归档一条龙
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Dynamic: license-file
|
|
10
|
+
|
|
11
|
+
# PicRename AI —— 图片批量 AI 重命名/整理器
|
|
12
|
+
|
|
13
|
+
> 把 `IMG_20230101_103055.jpg` 变成 `旅行_日本_东京塔_2026.jpg`:AI 看图 → 中文语义命名 → 归档,一条龙。
|
|
14
|
+
|
|
15
|
+
[](https://www.python.org/)
|
|
16
|
+
[](LICENSE)
|
|
17
|
+
[](tests/)
|
|
18
|
+
[](requirements.txt)
|
|
19
|
+
|
|
20
|
+
高频、隐私刚需场景:相册几百张图批量整理。图片压缩至最长边 800px 后才发送给视觉模型,支持完全本地模型(Ollama 等)。
|
|
21
|
+
|
|
22
|
+
## 功能特性
|
|
23
|
+
|
|
24
|
+
| 能力 | 说明 |
|
|
25
|
+
|---|---|
|
|
26
|
+
| AI 视觉识别 | 主体/场景分类/置信度,category 封闭枚举校验(旅行/美食/文档/宠物/人物/街拍/风景/运动/建筑/其他) |
|
|
27
|
+
| 模板化命名 | `{date}_{category}_{subject}_{index}.{ext}`,支持 strftime 自定义日期格式 |
|
|
28
|
+
| 安全第一 | 默认 `--dry-run` 只预览;真实改名必须显式 `--execute` |
|
|
29
|
+
| 可回滚 | 每次执行记录 `rename_map.csv`,`--undo` 一键回滚(含 mtime 恢复) |
|
|
30
|
+
| 绝不覆盖 | 目标重名自动追加 `_1` 序号;非法字符过滤;可选按类别移入子目录 |
|
|
31
|
+
| 三种界面 | CLI / 零依赖 Web UI / JSON 输出 |
|
|
32
|
+
|
|
33
|
+
## 快速开始
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install -e . # 安装 picren 命令(Pillow / httpx 自动装)
|
|
37
|
+
|
|
38
|
+
# 生成示例图(可选)
|
|
39
|
+
python sample\gen_sample.py
|
|
40
|
+
|
|
41
|
+
# 配置 API Key(优先级:--api-key > 环境变量 API_KEY > 工作目录 .env)
|
|
42
|
+
set API_KEY=sk-xxxx # Windows
|
|
43
|
+
|
|
44
|
+
# dry-run 预览(默认,不修改任何文件)
|
|
45
|
+
picren --dir sample --dry-run
|
|
46
|
+
|
|
47
|
+
# 真实执行(自动记录 rename_map.csv,可 --undo 回滚)
|
|
48
|
+
picren --dir photos --execute --move-by-category
|
|
49
|
+
|
|
50
|
+
# 回滚
|
|
51
|
+
picren --dir photos --undo
|
|
52
|
+
|
|
53
|
+
# 本地模型(Ollama OpenAI 兼容端点)
|
|
54
|
+
picren --dir photos --api-base http://localhost:11434/v1 --model qwen2.5vl --api-key ollama
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## 命令参考
|
|
58
|
+
|
|
59
|
+
| 参数 | 说明 |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `--dir DIR` | 图片目录(必选) |
|
|
62
|
+
| `--recursive` | 递归子目录 |
|
|
63
|
+
| `--pattern "*.jpg"` | 扩展名/通配模式,可重复 |
|
|
64
|
+
| `--template` | 命名模板(默认 `{date}_{category}_{subject}_{index}.{ext}`) |
|
|
65
|
+
| `--dry-run` / `--execute` | 预览 / 执行(默认预览) |
|
|
66
|
+
| `--undo` | 回滚最近的 rename_map.csv |
|
|
67
|
+
| `--move-by-category` | 按类别移入子目录 |
|
|
68
|
+
| `--tags-only` | 只生成 tags.csv,不改名 |
|
|
69
|
+
| `--workers N` | 并发线程数(默认 4) |
|
|
70
|
+
|
|
71
|
+
退出码:`0` 成功 · `1` 参数错误/无 Key · `2` 无匹配文件 · `3` 识别质量过低(超过 `--max-fail-ratio` 阈值中止)。
|
|
72
|
+
|
|
73
|
+
## 项目结构
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
main.py CLI 入口、API key 优先级、退出码
|
|
77
|
+
pipeline.py 收集 → 并发识别 → 渲染计划 → 执行/预览
|
|
78
|
+
vision.py 视觉模型调用与严格 JSON 解析(不访问文件系统)
|
|
79
|
+
reader.py 图片收集与预览压缩
|
|
80
|
+
rename_rules.py 模板渲染、非法字符过滤、重名去重
|
|
81
|
+
executor.py 改名执行 + undo_map 追加(绝不覆盖)
|
|
82
|
+
undo.py 回滚解析与逆序执行
|
|
83
|
+
webui.py + webui.html 零依赖 Web 界面
|
|
84
|
+
tests/ pytest 测试(110 例,不触网)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## 测试
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
python -m pytest tests/ -q
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## 隐私与免责
|
|
94
|
+
|
|
95
|
+
- 图片压缩至最长边 800px 后才发送到模型服务;完全本地模型(Ollama)可零上传使用。
|
|
96
|
+
- 工具只做文件名/目录层操作,**绝不删除文件**;`--keep-original`(默认开)让新名保留原名便于回溯。
|
|
97
|
+
- AI 命名仅供参考,执行前请核对 dry-run 预览;建议在副本目录上先试验。
|
|
98
|
+
|
|
99
|
+
本项目仅供学习交流与演示用途,不构成任何形式的商业服务或技术承诺。软件按「现状」提供,不作任何明示或暗示的保证,包括但不限于适销性、特定用途适用性与非侵权性。
|
|
100
|
+
您理解并同意:使用本项目即表示您自行承担全部风险。如您在使用过程中发现缺陷或问题,欢迎通过 GitHub Issues 反馈,但作者不因使用本软件所直接或间接产生的任何损失(包括但不限于数据丢失、业务中断、第三方索赔)承担责任。
|
|
101
|
+
本项目以功能演示与学习交流为主要目的,其架构设计、安全基线、容错机制与性能表现均未按生产级标准进行验证与加固,不适用于实际生产环境或关键业务场景。任何将本项目部署于生产系统、对外提供服务、或将其接入真实业务工作流的做法,均属使用者的自主决策行为;由此产生的任何直接或间接不良后果,包括但不限于服务中断、数据损坏或泄露、业务损失、合规风险、以及因依赖本软件而引发的第三方纠纷,**开发者均不承担任何责任**。若您确有生产级使用需求,请在充分评估与自行加固(包括但不限于安全审计、压力测试、代码审查)后,自行承担相应风险。
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
[GPL-3.0](LICENSE) — Copyright (C) 2026 anyuer678
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# PicRename AI —— 图片批量 AI 重命名/整理器
|
|
2
|
+
|
|
3
|
+
> 把 `IMG_20230101_103055.jpg` 变成 `旅行_日本_东京塔_2026.jpg`:AI 看图 → 中文语义命名 → 归档,一条龙。
|
|
4
|
+
|
|
5
|
+
[](https://www.python.org/)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](tests/)
|
|
8
|
+
[](requirements.txt)
|
|
9
|
+
|
|
10
|
+
高频、隐私刚需场景:相册几百张图批量整理。图片压缩至最长边 800px 后才发送给视觉模型,支持完全本地模型(Ollama 等)。
|
|
11
|
+
|
|
12
|
+
## 功能特性
|
|
13
|
+
|
|
14
|
+
| 能力 | 说明 |
|
|
15
|
+
|---|---|
|
|
16
|
+
| AI 视觉识别 | 主体/场景分类/置信度,category 封闭枚举校验(旅行/美食/文档/宠物/人物/街拍/风景/运动/建筑/其他) |
|
|
17
|
+
| 模板化命名 | `{date}_{category}_{subject}_{index}.{ext}`,支持 strftime 自定义日期格式 |
|
|
18
|
+
| 安全第一 | 默认 `--dry-run` 只预览;真实改名必须显式 `--execute` |
|
|
19
|
+
| 可回滚 | 每次执行记录 `rename_map.csv`,`--undo` 一键回滚(含 mtime 恢复) |
|
|
20
|
+
| 绝不覆盖 | 目标重名自动追加 `_1` 序号;非法字符过滤;可选按类别移入子目录 |
|
|
21
|
+
| 三种界面 | CLI / 零依赖 Web UI / JSON 输出 |
|
|
22
|
+
|
|
23
|
+
## 快速开始
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install -e . # 安装 picren 命令(Pillow / httpx 自动装)
|
|
27
|
+
|
|
28
|
+
# 生成示例图(可选)
|
|
29
|
+
python sample\gen_sample.py
|
|
30
|
+
|
|
31
|
+
# 配置 API Key(优先级:--api-key > 环境变量 API_KEY > 工作目录 .env)
|
|
32
|
+
set API_KEY=sk-xxxx # Windows
|
|
33
|
+
|
|
34
|
+
# dry-run 预览(默认,不修改任何文件)
|
|
35
|
+
picren --dir sample --dry-run
|
|
36
|
+
|
|
37
|
+
# 真实执行(自动记录 rename_map.csv,可 --undo 回滚)
|
|
38
|
+
picren --dir photos --execute --move-by-category
|
|
39
|
+
|
|
40
|
+
# 回滚
|
|
41
|
+
picren --dir photos --undo
|
|
42
|
+
|
|
43
|
+
# 本地模型(Ollama OpenAI 兼容端点)
|
|
44
|
+
picren --dir photos --api-base http://localhost:11434/v1 --model qwen2.5vl --api-key ollama
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## 命令参考
|
|
48
|
+
|
|
49
|
+
| 参数 | 说明 |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `--dir DIR` | 图片目录(必选) |
|
|
52
|
+
| `--recursive` | 递归子目录 |
|
|
53
|
+
| `--pattern "*.jpg"` | 扩展名/通配模式,可重复 |
|
|
54
|
+
| `--template` | 命名模板(默认 `{date}_{category}_{subject}_{index}.{ext}`) |
|
|
55
|
+
| `--dry-run` / `--execute` | 预览 / 执行(默认预览) |
|
|
56
|
+
| `--undo` | 回滚最近的 rename_map.csv |
|
|
57
|
+
| `--move-by-category` | 按类别移入子目录 |
|
|
58
|
+
| `--tags-only` | 只生成 tags.csv,不改名 |
|
|
59
|
+
| `--workers N` | 并发线程数(默认 4) |
|
|
60
|
+
|
|
61
|
+
退出码:`0` 成功 · `1` 参数错误/无 Key · `2` 无匹配文件 · `3` 识别质量过低(超过 `--max-fail-ratio` 阈值中止)。
|
|
62
|
+
|
|
63
|
+
## 项目结构
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
main.py CLI 入口、API key 优先级、退出码
|
|
67
|
+
pipeline.py 收集 → 并发识别 → 渲染计划 → 执行/预览
|
|
68
|
+
vision.py 视觉模型调用与严格 JSON 解析(不访问文件系统)
|
|
69
|
+
reader.py 图片收集与预览压缩
|
|
70
|
+
rename_rules.py 模板渲染、非法字符过滤、重名去重
|
|
71
|
+
executor.py 改名执行 + undo_map 追加(绝不覆盖)
|
|
72
|
+
undo.py 回滚解析与逆序执行
|
|
73
|
+
webui.py + webui.html 零依赖 Web 界面
|
|
74
|
+
tests/ pytest 测试(110 例,不触网)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## 测试
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
python -m pytest tests/ -q
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## 隐私与免责
|
|
84
|
+
|
|
85
|
+
- 图片压缩至最长边 800px 后才发送到模型服务;完全本地模型(Ollama)可零上传使用。
|
|
86
|
+
- 工具只做文件名/目录层操作,**绝不删除文件**;`--keep-original`(默认开)让新名保留原名便于回溯。
|
|
87
|
+
- AI 命名仅供参考,执行前请核对 dry-run 预览;建议在副本目录上先试验。
|
|
88
|
+
|
|
89
|
+
本项目仅供学习交流与演示用途,不构成任何形式的商业服务或技术承诺。软件按「现状」提供,不作任何明示或暗示的保证,包括但不限于适销性、特定用途适用性与非侵权性。
|
|
90
|
+
您理解并同意:使用本项目即表示您自行承担全部风险。如您在使用过程中发现缺陷或问题,欢迎通过 GitHub Issues 反馈,但作者不因使用本软件所直接或间接产生的任何损失(包括但不限于数据丢失、业务中断、第三方索赔)承担责任。
|
|
91
|
+
本项目以功能演示与学习交流为主要目的,其架构设计、安全基线、容错机制与性能表现均未按生产级标准进行验证与加固,不适用于实际生产环境或关键业务场景。任何将本项目部署于生产系统、对外提供服务、或将其接入真实业务工作流的做法,均属使用者的自主决策行为;由此产生的任何直接或间接不良后果,包括但不限于服务中断、数据损坏或泄露、业务损失、合规风险、以及因依赖本软件而引发的第三方纠纷,**开发者均不承担任何责任**。若您确有生产级使用需求,请在充分评估与自行加固(包括但不限于安全审计、压力测试、代码审查)后,自行承担相应风险。
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
[GPL-3.0](LICENSE) — Copyright (C) 2026 anyuer678
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""改名执行与 dry-run 展示:逐个 os.rename、失败隔离、成功后追加 undo_map。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import List, Tuple
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class RenameOp:
|
|
13
|
+
src_abs: str
|
|
14
|
+
dst_abs: str
|
|
15
|
+
mtime_before: float
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ApplyStats:
|
|
20
|
+
renamed: int
|
|
21
|
+
failed: list # [(src, err), ...]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_HEADER = ("old_name", "new_name", "mtime")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _map_dir(path: str) -> str:
|
|
28
|
+
return os.path.dirname(os.path.abspath(path))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _rel(path: str, base: str) -> str:
|
|
32
|
+
try:
|
|
33
|
+
return os.path.relpath(path, base)
|
|
34
|
+
except ValueError: # 跨盘符时回退绝对路径
|
|
35
|
+
return path
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _fmt_mtime(mtime: float) -> str:
|
|
39
|
+
return repr(mtime) if mtime else "0"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _append_map_row(undo_map: str, op: RenameOp) -> None:
|
|
43
|
+
base = _map_dir(undo_map)
|
|
44
|
+
fresh = not os.path.exists(undo_map) or os.path.getsize(undo_map) == 0
|
|
45
|
+
with open(undo_map, "a", newline="", encoding="utf-8-sig") as f:
|
|
46
|
+
writer = csv.writer(f)
|
|
47
|
+
if fresh:
|
|
48
|
+
writer.writerow(_HEADER)
|
|
49
|
+
writer.writerow([_rel(op.src_abs, base), _rel(op.dst_abs, base), _fmt_mtime(op.mtime_before)])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def apply_renames(ops: List[RenameOp], *, undo_map: str = "rename_map.csv") -> ApplyStats:
|
|
53
|
+
"""逐个 os.rename;单个失败只记录不中断整批,成功后 append 一行到 undo_map。"""
|
|
54
|
+
ops = [RenameOp(os.path.abspath(o.src_abs), os.path.abspath(o.dst_abs), o.mtime_before)
|
|
55
|
+
for o in ops]
|
|
56
|
+
stats = ApplyStats(renamed=0, failed=[])
|
|
57
|
+
for op in ops:
|
|
58
|
+
if os.path.lexists(op.dst_abs):
|
|
59
|
+
stats.failed.append((op.src_abs, "目标已存在,拒绝覆盖: %s" % op.dst_abs))
|
|
60
|
+
continue
|
|
61
|
+
try:
|
|
62
|
+
os.rename(op.src_abs, op.dst_abs)
|
|
63
|
+
except OSError as e:
|
|
64
|
+
stats.failed.append((op.src_abs, str(e)))
|
|
65
|
+
continue
|
|
66
|
+
_append_map_row(undo_map, op)
|
|
67
|
+
stats.renamed += 1
|
|
68
|
+
return stats
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def dry_run_render(ops: List[RenameOp]) -> List[Tuple[str, str]]:
|
|
72
|
+
"""纯展示 (src→dst) 列表,绝不触碰文件系统。"""
|
|
73
|
+
return [(op.src_abs, op.dst_abs) for op in ops]
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""PicRename AI 命令行入口:参数解析、API key 优先级、退出码、tags/undo 装配。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import csv
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from .pipeline import UNDO_MAP_NAME, PipelineConfig, run_pipeline
|
|
12
|
+
from .undo import undo_from_map
|
|
13
|
+
|
|
14
|
+
DEFAULT_API_BASE = "https://api.openai.com/v1"
|
|
15
|
+
DEFAULT_MODEL = "gpt-4o-mini"
|
|
16
|
+
KEY_NAMES = ("PICREN_API_KEY", "PICRENAME_API_KEY", "API_KEY") # 首选专用名,旧名向后兼容
|
|
17
|
+
|
|
18
|
+
KEY_HINT = (
|
|
19
|
+
"[picren] 错误:未找到 API Key,无法调用视觉模型。\n"
|
|
20
|
+
" 请用以下任一方式提供:\n"
|
|
21
|
+
" 1) 命令行参数:--api-key <你的Key>\n"
|
|
22
|
+
" 2) 环境变量:API_KEY=<你的Key>\n"
|
|
23
|
+
" 3) 工作目录 .env 文件写入:API_KEY=sk-xxx\n"
|
|
24
|
+
" 提示:图片会压缩至最长边 800px 后发送给第三方模型服务;\n"
|
|
25
|
+
" 本地模型(如 Ollama)可传 --api-key ollama。"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _UsageError(Exception):
|
|
30
|
+
"""参数错误(退出码 1)。"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _Parser(argparse.ArgumentParser):
|
|
34
|
+
def error(self, message):
|
|
35
|
+
raise _UsageError(message)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _build_parser() -> _Parser:
|
|
39
|
+
p = _Parser(prog="picren",
|
|
40
|
+
description="图片批量 AI 重命名/整理器(PicRename AI)")
|
|
41
|
+
p.add_argument("--dir", help="图片目录")
|
|
42
|
+
p.add_argument("--recursive", action="store_true", help="递归子目录")
|
|
43
|
+
p.add_argument("--pattern", action="append", default=[],
|
|
44
|
+
help="扩展名/通配模式,可重复(如 *.jpg)")
|
|
45
|
+
p.add_argument("--template", default="{date}_{category}_{subject}_{index}.{ext}",
|
|
46
|
+
help="命名模板")
|
|
47
|
+
p.add_argument("--tags-only", action="store_true", help="只生成 tags.csv,不改名")
|
|
48
|
+
mode = p.add_mutually_exclusive_group()
|
|
49
|
+
mode.add_argument("--dry-run", action="store_true", help="仅预览,不写盘(默认模式)")
|
|
50
|
+
mode.add_argument("--execute", action="store_true", help="真实执行(先自动预览)")
|
|
51
|
+
p.add_argument("--undo", action="store_true", help="回滚最近的 rename_map.csv")
|
|
52
|
+
p.add_argument("--move-by-category", action="store_true", help="按类别移入子目录")
|
|
53
|
+
p.add_argument("--keep-original", action="store_true", default=True,
|
|
54
|
+
help="目标名保留原名以可回溯(默认开启)")
|
|
55
|
+
p.add_argument("--no-keep-original", dest="keep_original", action="store_false")
|
|
56
|
+
p.add_argument("--api-base", default=DEFAULT_API_BASE, help="OpenAI 兼容 API 地址")
|
|
57
|
+
p.add_argument("--model", default=DEFAULT_MODEL, help="视觉模型名")
|
|
58
|
+
p.add_argument("--api-key", default=None, help="API key(也可用环境变量/.env)")
|
|
59
|
+
p.add_argument("--workers", type=int, default=4, help="并发线程数")
|
|
60
|
+
p.add_argument("--max-fail-ratio", type=float, default=0.2,
|
|
61
|
+
help="识别失败比例阈值,超过则中止")
|
|
62
|
+
p.add_argument("--json", action="store_true", help="以 JSON 输出结果")
|
|
63
|
+
return p
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _read_dotenv_key() -> str:
|
|
67
|
+
"""从工作目录 .env 中读取 API_KEY(手写 dotenv,不引依赖)。"""
|
|
68
|
+
path = os.path.join(os.getcwd(), ".env")
|
|
69
|
+
if not os.path.exists(path):
|
|
70
|
+
return ""
|
|
71
|
+
try:
|
|
72
|
+
with open(path, encoding="utf-8") as f:
|
|
73
|
+
for line in f:
|
|
74
|
+
line = line.strip()
|
|
75
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
76
|
+
continue
|
|
77
|
+
k, v = line.split("=", 1)
|
|
78
|
+
if k.strip() in KEY_NAMES and v.strip():
|
|
79
|
+
return v.strip()
|
|
80
|
+
except OSError:
|
|
81
|
+
return ""
|
|
82
|
+
return ""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _load_api_key(cli_key) -> str:
|
|
86
|
+
"""key 优先级:--api-key > 环境变量 > .env 文件。"""
|
|
87
|
+
if cli_key:
|
|
88
|
+
return cli_key
|
|
89
|
+
for name in KEY_NAMES:
|
|
90
|
+
v = os.environ.get(name)
|
|
91
|
+
if v:
|
|
92
|
+
return v
|
|
93
|
+
return _read_dotenv_key()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _iter_plan(planned):
|
|
97
|
+
"""把 planned 元素统一为 (src, dst) 元组(兼容 RenameOp 与二元组)。"""
|
|
98
|
+
for item in planned:
|
|
99
|
+
if hasattr(item, "src_abs"):
|
|
100
|
+
yield item.src_abs, item.dst_abs
|
|
101
|
+
else:
|
|
102
|
+
yield item
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _format_plan(planned) -> str:
|
|
106
|
+
"""把 [(src, dst)] 渲染成对齐的文本表。"""
|
|
107
|
+
if not planned:
|
|
108
|
+
return " (无)"
|
|
109
|
+
items = list(_iter_plan(planned))
|
|
110
|
+
width = max((len(str(s)) for s, _ in items), default=0)
|
|
111
|
+
lines = [f" {src:<{width}} -> {dst}" for src, dst in items]
|
|
112
|
+
return "\n".join(lines)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _write_tags_csv(directory: str, report) -> str:
|
|
116
|
+
"""写 tags.csv(文件名,日期,类别,主题,场景,置信度)。"""
|
|
117
|
+
path = os.path.join(directory, "tags.csv")
|
|
118
|
+
with open(path, "w", newline="", encoding="utf-8-sig") as f:
|
|
119
|
+
w = csv.writer(f)
|
|
120
|
+
w.writerow(["文件名", "日期", "类别", "主题", "场景", "置信度"])
|
|
121
|
+
for img, result in (report.tagged or []):
|
|
122
|
+
rel = os.path.relpath(img.path, directory)
|
|
123
|
+
date = img.taken_at.strftime("%Y-%m-%d") if img.taken_at else ""
|
|
124
|
+
w.writerow([rel, date, result.category, result.subject,
|
|
125
|
+
result.scene, f"{result.confidence:.2f}"])
|
|
126
|
+
return path
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _report_to_json(report) -> dict:
|
|
130
|
+
"""把 PipelineReport 序列化为 JSON 可打印结构。"""
|
|
131
|
+
executed = None
|
|
132
|
+
if report.executed is not None:
|
|
133
|
+
executed = {
|
|
134
|
+
"renamed": report.executed.renamed,
|
|
135
|
+
"failed": [[src, str(err)] for src, err in report.executed.failed],
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
"planned": [[src, dst] for src, dst in _iter_plan(report.planned)],
|
|
139
|
+
"executed": executed,
|
|
140
|
+
"failed": [[path, err] for path, err in report.failed],
|
|
141
|
+
"tags": [
|
|
142
|
+
{"subject": t.subject, "category": t.category, "scene": t.scene,
|
|
143
|
+
"confidence": t.confidence}
|
|
144
|
+
for t in report.tags
|
|
145
|
+
],
|
|
146
|
+
"elapsed_s": round(report.elapsed_s, 3),
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _cmd_undo(args) -> int:
|
|
151
|
+
"""--undo:读取目录下最近的 rename_map.csv 回滚。"""
|
|
152
|
+
directory = args.dir or os.getcwd()
|
|
153
|
+
map_path = os.path.join(directory, UNDO_MAP_NAME)
|
|
154
|
+
if not os.path.exists(map_path):
|
|
155
|
+
print(f"[picren] 未找到回滚映射:{map_path}", file=sys.stderr)
|
|
156
|
+
return 1
|
|
157
|
+
stats = undo_from_map(map_path)
|
|
158
|
+
print(f"[picren] 已回滚 {stats.renamed} 个文件"
|
|
159
|
+
+ (f",失败 {len(stats.failed)} 个" if stats.failed else ""))
|
|
160
|
+
for src, err in stats.failed:
|
|
161
|
+
print(f" - {src}: {err}", file=sys.stderr)
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _cmd_run(args) -> int:
|
|
166
|
+
"""--dir 主流程:校验→建配置→跑管道→按退出码/模式输出。"""
|
|
167
|
+
if not args.dir:
|
|
168
|
+
raise _UsageError("缺少必选参数 --dir")
|
|
169
|
+
if not os.path.isdir(args.dir):
|
|
170
|
+
raise _UsageError(f"目录不存在:{args.dir}")
|
|
171
|
+
key = _load_api_key(args.api_key)
|
|
172
|
+
if not key:
|
|
173
|
+
print(KEY_HINT, file=sys.stderr)
|
|
174
|
+
return 1
|
|
175
|
+
cfg = PipelineConfig(
|
|
176
|
+
directory=args.dir,
|
|
177
|
+
recursive=args.recursive,
|
|
178
|
+
patterns=args.pattern or None,
|
|
179
|
+
template=args.template,
|
|
180
|
+
dry_run_first=not args.execute, # 默认 dry-run;--execute 才落盘(--dry-run 与其互斥)
|
|
181
|
+
workers=max(1, args.workers),
|
|
182
|
+
api_base=args.api_base,
|
|
183
|
+
model=args.model,
|
|
184
|
+
api_key=key,
|
|
185
|
+
move_by_category=args.move_by_category,
|
|
186
|
+
keep_original=args.keep_original,
|
|
187
|
+
max_fail_ratio=args.max_fail_ratio,
|
|
188
|
+
)
|
|
189
|
+
report = run_pipeline(cfg)
|
|
190
|
+
total = len(report.failed) + len(report.tags)
|
|
191
|
+
if total == 0:
|
|
192
|
+
print(f"[picren] 未找到匹配的图片文件:{args.dir}", file=sys.stderr)
|
|
193
|
+
return 2
|
|
194
|
+
fail_ratio = len(report.failed) / total
|
|
195
|
+
if fail_ratio > cfg.max_fail_ratio:
|
|
196
|
+
print(f"[picren] 识别质量过低(失败 {len(report.failed)}/{total}),已中止;"
|
|
197
|
+
f"阈值 {cfg.max_fail_ratio:.0%}", file=sys.stderr)
|
|
198
|
+
for path, err in report.failed[:5]:
|
|
199
|
+
print(f" - {path}: {err}", file=sys.stderr)
|
|
200
|
+
return 3
|
|
201
|
+
if args.tags_only:
|
|
202
|
+
path = _write_tags_csv(args.dir, report)
|
|
203
|
+
print(f"[picren] 已生成 {path}({len(report.tags)} 行),未改名")
|
|
204
|
+
return 0
|
|
205
|
+
if args.json:
|
|
206
|
+
print(json.dumps(_report_to_json(report), ensure_ascii=False, indent=2))
|
|
207
|
+
return 0
|
|
208
|
+
mode = "干跑预览(未修改任何文件)" if not args.execute else "执行前预览"
|
|
209
|
+
print(f"[picren] 收集 {total} 张,成功识别 {len(report.tags)} 张"
|
|
210
|
+
f"(失败 {len(report.failed)}),耗时 {report.elapsed_s:.1f}s:{mode}")
|
|
211
|
+
print(_format_plan(report.planned))
|
|
212
|
+
if args.execute:
|
|
213
|
+
stats = report.executed
|
|
214
|
+
if stats is None:
|
|
215
|
+
print("[picren] 未执行(无可用计划)", file=sys.stderr)
|
|
216
|
+
return 1
|
|
217
|
+
print(f"[picren] 已重命名 {stats.renamed} 个文件"
|
|
218
|
+
+ (f",失败 {len(stats.failed)} 个" if stats.failed else ""))
|
|
219
|
+
for src, err in stats.failed:
|
|
220
|
+
print(f" - {src}: {err}", file=sys.stderr)
|
|
221
|
+
return 0
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _fix_win_encoding() -> None:
|
|
225
|
+
"""Windows 下重定向/管道输出统一为 UTF-8,避免中文乱码。"""
|
|
226
|
+
if sys.platform != "win32":
|
|
227
|
+
return
|
|
228
|
+
for stream in (sys.stdout, sys.stderr):
|
|
229
|
+
try:
|
|
230
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
231
|
+
except (AttributeError, ValueError):
|
|
232
|
+
pass
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def main(argv=None) -> int:
|
|
236
|
+
"""CLI 入口,返回进程退出码。"""
|
|
237
|
+
_fix_win_encoding()
|
|
238
|
+
parser = _build_parser()
|
|
239
|
+
try:
|
|
240
|
+
args = parser.parse_args(argv)
|
|
241
|
+
if args.undo:
|
|
242
|
+
return _cmd_undo(args)
|
|
243
|
+
return _cmd_run(args)
|
|
244
|
+
except _UsageError as exc:
|
|
245
|
+
print(f"[picren] 参数错误:{exc}", file=sys.stderr)
|
|
246
|
+
parser.print_usage(file=sys.stderr)
|
|
247
|
+
return 1
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
if __name__ == "__main__":
|
|
251
|
+
sys.exit(main())
|