anime-core-lib-portrait-tool 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.
- anime_core_lib_portrait_tool-0.1.0/.gitignore +224 -0
- anime_core_lib_portrait_tool-0.1.0/LICENSE +21 -0
- anime_core_lib_portrait_tool-0.1.0/PKG-INFO +90 -0
- anime_core_lib_portrait_tool-0.1.0/README.md +70 -0
- anime_core_lib_portrait_tool-0.1.0/anime_core_lib_portrait_tool/__init__.py +4 -0
- anime_core_lib_portrait_tool-0.1.0/anime_core_lib_portrait_tool/auto.py +17 -0
- anime_core_lib_portrait_tool-0.1.0/anime_core_lib_portrait_tool/cli.py +38 -0
- anime_core_lib_portrait_tool-0.1.0/anime_core_lib_portrait_tool/config.py +34 -0
- anime_core_lib_portrait_tool-0.1.0/anime_core_lib_portrait_tool/dds.py +26 -0
- anime_core_lib_portrait_tool-0.1.0/anime_core_lib_portrait_tool/encoders/__init__.py +6 -0
- anime_core_lib_portrait_tool-0.1.0/anime_core_lib_portrait_tool/encoders/nvtt.py +14 -0
- anime_core_lib_portrait_tool-0.1.0/anime_core_lib_portrait_tool/encoders/texconv.py +19 -0
- anime_core_lib_portrait_tool-0.1.0/anime_core_lib_portrait_tool/manual.py +199 -0
- anime_core_lib_portrait_tool-0.1.0/anime_core_lib_portrait_tool/transform.py +77 -0
- anime_core_lib_portrait_tool-0.1.0/examples/batch.py +60 -0
- anime_core_lib_portrait_tool-0.1.0/examples/jobs.csv +2 -0
- anime_core_lib_portrait_tool-0.1.0/examples/layout.json +23 -0
- anime_core_lib_portrait_tool-0.1.0/portrait.py +6 -0
- anime_core_lib_portrait_tool-0.1.0/pyproject.toml +31 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
*.lcov
|
|
51
|
+
.hypothesis/
|
|
52
|
+
.pytest_cache/
|
|
53
|
+
cover/
|
|
54
|
+
|
|
55
|
+
# Translations
|
|
56
|
+
*.mo
|
|
57
|
+
*.pot
|
|
58
|
+
|
|
59
|
+
# Django stuff:
|
|
60
|
+
*.log
|
|
61
|
+
local_settings.py
|
|
62
|
+
db.sqlite3
|
|
63
|
+
db.sqlite3-journal
|
|
64
|
+
|
|
65
|
+
# Flask stuff:
|
|
66
|
+
instance/
|
|
67
|
+
.webassets-cache
|
|
68
|
+
|
|
69
|
+
# Scrapy stuff:
|
|
70
|
+
.scrapy
|
|
71
|
+
|
|
72
|
+
# Sphinx documentation
|
|
73
|
+
docs/_build/
|
|
74
|
+
|
|
75
|
+
# PyBuilder
|
|
76
|
+
.pybuilder/
|
|
77
|
+
target/
|
|
78
|
+
|
|
79
|
+
# Jupyter Notebook
|
|
80
|
+
.ipynb_checkpoints
|
|
81
|
+
|
|
82
|
+
# IPython
|
|
83
|
+
profile_default/
|
|
84
|
+
ipython_config.py
|
|
85
|
+
|
|
86
|
+
# pyenv
|
|
87
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
88
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
89
|
+
# .python-version
|
|
90
|
+
|
|
91
|
+
# pipenv
|
|
92
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
93
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
94
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
95
|
+
# install all needed dependencies.
|
|
96
|
+
# Pipfile.lock
|
|
97
|
+
|
|
98
|
+
# uv
|
|
99
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
100
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
101
|
+
# commonly ignored for libraries.
|
|
102
|
+
# uv.lock
|
|
103
|
+
|
|
104
|
+
# poetry
|
|
105
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
106
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
107
|
+
# commonly ignored for libraries.
|
|
108
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
109
|
+
# poetry.lock
|
|
110
|
+
# poetry.toml
|
|
111
|
+
|
|
112
|
+
# pdm
|
|
113
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
114
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
115
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
116
|
+
# pdm.lock
|
|
117
|
+
# pdm.toml
|
|
118
|
+
.pdm-python
|
|
119
|
+
.pdm-build/
|
|
120
|
+
|
|
121
|
+
# pixi
|
|
122
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
123
|
+
# pixi.lock
|
|
124
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
125
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
126
|
+
.pixi/*
|
|
127
|
+
!.pixi/config.toml
|
|
128
|
+
|
|
129
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
130
|
+
__pypackages__/
|
|
131
|
+
|
|
132
|
+
# Celery stuff
|
|
133
|
+
celerybeat-schedule*
|
|
134
|
+
celerybeat.pid
|
|
135
|
+
|
|
136
|
+
# Redis
|
|
137
|
+
*.rdb
|
|
138
|
+
*.aof
|
|
139
|
+
*.pid
|
|
140
|
+
|
|
141
|
+
# RabbitMQ
|
|
142
|
+
mnesia/
|
|
143
|
+
rabbitmq/
|
|
144
|
+
rabbitmq-data/
|
|
145
|
+
|
|
146
|
+
# ActiveMQ
|
|
147
|
+
activemq-data/
|
|
148
|
+
|
|
149
|
+
# SageMath parsed files
|
|
150
|
+
*.sage.py
|
|
151
|
+
|
|
152
|
+
# Environments
|
|
153
|
+
.env
|
|
154
|
+
.envrc
|
|
155
|
+
.venv
|
|
156
|
+
env/
|
|
157
|
+
venv/
|
|
158
|
+
ENV/
|
|
159
|
+
env.bak/
|
|
160
|
+
venv.bak/
|
|
161
|
+
|
|
162
|
+
# Spyder project settings
|
|
163
|
+
.spyderproject
|
|
164
|
+
.spyproject
|
|
165
|
+
|
|
166
|
+
# Rope project settings
|
|
167
|
+
.ropeproject
|
|
168
|
+
|
|
169
|
+
# mkdocs/Zensical documentation
|
|
170
|
+
/site
|
|
171
|
+
|
|
172
|
+
# mypy
|
|
173
|
+
.mypy_cache/
|
|
174
|
+
.dmypy.json
|
|
175
|
+
dmypy.json
|
|
176
|
+
|
|
177
|
+
# Pyre type checker
|
|
178
|
+
.pyre/
|
|
179
|
+
|
|
180
|
+
# pytype static type analyzer
|
|
181
|
+
.pytype/
|
|
182
|
+
|
|
183
|
+
# Cython debug symbols
|
|
184
|
+
cython_debug/
|
|
185
|
+
|
|
186
|
+
# PyCharm
|
|
187
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
188
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
189
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
190
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
191
|
+
# .idea/
|
|
192
|
+
|
|
193
|
+
# Abstra
|
|
194
|
+
# Abstra is an AI-powered process automation framework.
|
|
195
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
196
|
+
# Learn more at https://abstra.io/docs
|
|
197
|
+
.abstra/
|
|
198
|
+
|
|
199
|
+
# Visual Studio Code
|
|
200
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore that
|
|
201
|
+
# can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
202
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer, you
|
|
203
|
+
# could uncomment the following to ignore the entire vscode folder
|
|
204
|
+
# .vscode/
|
|
205
|
+
# Temporary file for partial code execution
|
|
206
|
+
tempCodeRunnerFile.py
|
|
207
|
+
|
|
208
|
+
# Ruff stuff:
|
|
209
|
+
.ruff_cache/
|
|
210
|
+
|
|
211
|
+
# PyPI configuration file
|
|
212
|
+
.pypirc
|
|
213
|
+
|
|
214
|
+
# Marimo
|
|
215
|
+
marimo/_static/
|
|
216
|
+
marimo/_lsp/
|
|
217
|
+
__marimo__/
|
|
218
|
+
|
|
219
|
+
# Streamlit
|
|
220
|
+
.streamlit/secrets.toml
|
|
221
|
+
|
|
222
|
+
# Local example assets and generated DDS files
|
|
223
|
+
/examples/images/
|
|
224
|
+
/examples/output/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 cwlm
|
|
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.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: anime-core-lib-portrait-tool
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Automatic and manual portrait composition and DDS export for Anime Core Lib
|
|
5
|
+
Project-URL: Repository, https://github.com/cwlm/anime_core_lib_portrait_tool
|
|
6
|
+
Author: cwlm
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Requires-Dist: pillow>=13.0.0
|
|
11
|
+
Provides-Extra: auto
|
|
12
|
+
Requires-Dist: dghs-imgutils>=0.19.0; extra == 'auto'
|
|
13
|
+
Requires-Dist: onnxruntime>=1.31.1; extra == 'auto'
|
|
14
|
+
Provides-Extra: gpu
|
|
15
|
+
Requires-Dist: dghs-imgutils>=0.19.0; extra == 'gpu'
|
|
16
|
+
Requires-Dist: onnxruntime-gpu>=1.31.1; extra == 'gpu'
|
|
17
|
+
Provides-Extra: gui
|
|
18
|
+
Requires-Dist: pyside6-essentials>=6.12.0; extra == 'gui'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# 透明立绘转 DDS
|
|
22
|
+
|
|
23
|
+
单图自动/手动处理工具,每次调用处理一张图片。默认输出 1024×1024(可配置)、直通 Alpha、单层 BC3/DXT5 DDS。
|
|
24
|
+
|
|
25
|
+
## 安装
|
|
26
|
+
|
|
27
|
+
项目使用 pyproject.toml 和 Hatchling 管理依赖与打包,需要 Python 3.12 或更新版本。Windows 上完整自动处理目前使用 Python 3.12:自动检测库依赖 NumPy 1.x,暂不能在更新的 Python 上安装。
|
|
28
|
+
|
|
29
|
+
在项目目录按需安装,pip 默认选择稳定版:
|
|
30
|
+
|
|
31
|
+
```powershell
|
|
32
|
+
# 自动处理(CPU)和手动界面
|
|
33
|
+
python -m pip install --upgrade ".[auto,gui]"
|
|
34
|
+
|
|
35
|
+
# NVIDIA GPU 自动处理和手动界面
|
|
36
|
+
python -m pip install --upgrade ".[gpu,gui]"
|
|
37
|
+
|
|
38
|
+
# 仅图片读取、渲染和编码接口
|
|
39
|
+
python -m pip install --upgrade .
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
auto 和 gpu 二选一,避免同时安装 CPU 和 GPU 版 ONNX Runtime。开发时可加 -e 使用可编辑安装。
|
|
43
|
+
|
|
44
|
+
NVTT 或 texconv 自行安装,调用时必须传入名称和可执行文件路径。
|
|
45
|
+
|
|
46
|
+
```powershell
|
|
47
|
+
portrait auto --input source.png --output result.dds --encoder nvtt --encoder-path 'C:/Tools/nvtt_export.exe' --model-cache ./models
|
|
48
|
+
portrait manual --input source.png --output result.dds --encoder texconv --encoder-path 'C:/Tools/texconv.exe'
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
手动窗口:左键拖动/滚轮调整图片;Ctrl+滚轮缩放查看,中键或右键平移视图。“查看完整画布”重置视图,查看操作不改变输出构图。右侧可以输入缩放百分比及左上角 X/Y,“立绘居中”重置排版。白框是输出画布,橙框是脸部参考,青框是原图边界,紫框是可见内容边界;辅助框不写入 DDS。点击保存或 Ctrl+S 导出,取消不写文件;默认禁止覆盖,--overwrite 允许覆盖。
|
|
52
|
+
|
|
53
|
+
命令行默认离线。--model-cache 指定 Hugging Face 缓存;需要下载模型时自行设置 HF_HUB_OFFLINE=0。
|
|
54
|
+
|
|
55
|
+
命令行退出码:0 已保存,2 需手动处理或已取消,1 处理错误。
|
|
56
|
+
|
|
57
|
+
## 画布与构图配置
|
|
58
|
+
|
|
59
|
+
默认画布为 1024×1024。自动和手动入口都支持 --layout,使用同一套画布、脸部参考及活动范围。配置示例见 examples/layout.json:
|
|
60
|
+
|
|
61
|
+
```powershell
|
|
62
|
+
portrait manual --input source.png --output result.dds --encoder nvtt --encoder-path 'C:/Tools/nvtt_export.exe' --layout examples/layout.json
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
修改 canvas 时,一并设置 anchor、face_size、anchor_x 和 anchor_y;坐标单位均为输出像素。margin 是内容边距,alpha_core 和 face_threshold 分别控制自动取景的 Alpha 阈值与主脸置信度。
|
|
66
|
+
|
|
67
|
+
Python 接口使用 load_layout(path) 或 Layout(...);将 layout 传给 prepare、estimate_transform 和 render 即可。
|
|
68
|
+
|
|
69
|
+
## Python 接口
|
|
70
|
+
|
|
71
|
+
auto.prepare(input_path, output_path, ...) 自动构图并导出,成功返回 True,无可靠主脸时返回 False。无脸、低分、大小相近的多脸和空透明图都可能需要手动构图。
|
|
72
|
+
|
|
73
|
+
manual.prepare(input_path, output_path, ...) 打开手动构图窗口,保存返回 True,取消返回 False。两个入口都接受 encoder、encoder_path、layout 和 overwrite 参数;默认禁止覆盖已有输出,处理错误会抛出异常。
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from anime_core_lib_portrait_tool.auto import prepare
|
|
77
|
+
from anime_core_lib_portrait_tool import read_image, estimate_transform, render, write_dds
|
|
78
|
+
|
|
79
|
+
prepare("source.png", "result.dds",
|
|
80
|
+
encoder="texconv", encoder_path="C:/Tools/texconv.exe")
|
|
81
|
+
|
|
82
|
+
image = read_image("source.png")
|
|
83
|
+
placement = estimate_transform(image) # 无可靠主脸时返回 None
|
|
84
|
+
if placement is not None:
|
|
85
|
+
candidate = render(image, placement)
|
|
86
|
+
write_dds("other.dds", candidate,
|
|
87
|
+
encoder="nvtt", encoder_path="C:/Tools/nvtt_export.exe")
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
数值接口使用 Placement(scale, x, y),坐标为 EXIF 转正后的原图坐标:输出 = 原图 × scale + 偏移。自动构图使用动漫人脸模型和 Alpha 内容边界;装饰面积较大的图片可能把人物缩得更小。
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# 透明立绘转 DDS
|
|
2
|
+
|
|
3
|
+
单图自动/手动处理工具,每次调用处理一张图片。默认输出 1024×1024(可配置)、直通 Alpha、单层 BC3/DXT5 DDS。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
项目使用 pyproject.toml 和 Hatchling 管理依赖与打包,需要 Python 3.12 或更新版本。Windows 上完整自动处理目前使用 Python 3.12:自动检测库依赖 NumPy 1.x,暂不能在更新的 Python 上安装。
|
|
8
|
+
|
|
9
|
+
在项目目录按需安装,pip 默认选择稳定版:
|
|
10
|
+
|
|
11
|
+
```powershell
|
|
12
|
+
# 自动处理(CPU)和手动界面
|
|
13
|
+
python -m pip install --upgrade ".[auto,gui]"
|
|
14
|
+
|
|
15
|
+
# NVIDIA GPU 自动处理和手动界面
|
|
16
|
+
python -m pip install --upgrade ".[gpu,gui]"
|
|
17
|
+
|
|
18
|
+
# 仅图片读取、渲染和编码接口
|
|
19
|
+
python -m pip install --upgrade .
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
auto 和 gpu 二选一,避免同时安装 CPU 和 GPU 版 ONNX Runtime。开发时可加 -e 使用可编辑安装。
|
|
23
|
+
|
|
24
|
+
NVTT 或 texconv 自行安装,调用时必须传入名称和可执行文件路径。
|
|
25
|
+
|
|
26
|
+
```powershell
|
|
27
|
+
portrait auto --input source.png --output result.dds --encoder nvtt --encoder-path 'C:/Tools/nvtt_export.exe' --model-cache ./models
|
|
28
|
+
portrait manual --input source.png --output result.dds --encoder texconv --encoder-path 'C:/Tools/texconv.exe'
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
手动窗口:左键拖动/滚轮调整图片;Ctrl+滚轮缩放查看,中键或右键平移视图。“查看完整画布”重置视图,查看操作不改变输出构图。右侧可以输入缩放百分比及左上角 X/Y,“立绘居中”重置排版。白框是输出画布,橙框是脸部参考,青框是原图边界,紫框是可见内容边界;辅助框不写入 DDS。点击保存或 Ctrl+S 导出,取消不写文件;默认禁止覆盖,--overwrite 允许覆盖。
|
|
32
|
+
|
|
33
|
+
命令行默认离线。--model-cache 指定 Hugging Face 缓存;需要下载模型时自行设置 HF_HUB_OFFLINE=0。
|
|
34
|
+
|
|
35
|
+
命令行退出码:0 已保存,2 需手动处理或已取消,1 处理错误。
|
|
36
|
+
|
|
37
|
+
## 画布与构图配置
|
|
38
|
+
|
|
39
|
+
默认画布为 1024×1024。自动和手动入口都支持 --layout,使用同一套画布、脸部参考及活动范围。配置示例见 examples/layout.json:
|
|
40
|
+
|
|
41
|
+
```powershell
|
|
42
|
+
portrait manual --input source.png --output result.dds --encoder nvtt --encoder-path 'C:/Tools/nvtt_export.exe' --layout examples/layout.json
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
修改 canvas 时,一并设置 anchor、face_size、anchor_x 和 anchor_y;坐标单位均为输出像素。margin 是内容边距,alpha_core 和 face_threshold 分别控制自动取景的 Alpha 阈值与主脸置信度。
|
|
46
|
+
|
|
47
|
+
Python 接口使用 load_layout(path) 或 Layout(...);将 layout 传给 prepare、estimate_transform 和 render 即可。
|
|
48
|
+
|
|
49
|
+
## Python 接口
|
|
50
|
+
|
|
51
|
+
auto.prepare(input_path, output_path, ...) 自动构图并导出,成功返回 True,无可靠主脸时返回 False。无脸、低分、大小相近的多脸和空透明图都可能需要手动构图。
|
|
52
|
+
|
|
53
|
+
manual.prepare(input_path, output_path, ...) 打开手动构图窗口,保存返回 True,取消返回 False。两个入口都接受 encoder、encoder_path、layout 和 overwrite 参数;默认禁止覆盖已有输出,处理错误会抛出异常。
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from anime_core_lib_portrait_tool.auto import prepare
|
|
57
|
+
from anime_core_lib_portrait_tool import read_image, estimate_transform, render, write_dds
|
|
58
|
+
|
|
59
|
+
prepare("source.png", "result.dds",
|
|
60
|
+
encoder="texconv", encoder_path="C:/Tools/texconv.exe")
|
|
61
|
+
|
|
62
|
+
image = read_image("source.png")
|
|
63
|
+
placement = estimate_transform(image) # 无可靠主脸时返回 None
|
|
64
|
+
if placement is not None:
|
|
65
|
+
candidate = render(image, placement)
|
|
66
|
+
write_dds("other.dds", candidate,
|
|
67
|
+
encoder="nvtt", encoder_path="C:/Tools/nvtt_export.exe")
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
数值接口使用 Placement(scale, x, y),坐标为 EXIF 转正后的原图坐标:输出 = 原图 × scale + 偏移。自动构图使用动漫人脸模型和 Alpha 内容边界;装饰面积较大的图片可能把人物缩得更小。
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Single-image automatic processing; False means manual composition is needed."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from .dds import write_dds
|
|
4
|
+
from .config import Layout
|
|
5
|
+
from .transform import read_image, estimate_transform, render
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def prepare(input_path, output_path, *, encoder, encoder_path, layout=Layout(), overwrite=False):
|
|
9
|
+
if Path(input_path).resolve() == Path(output_path).resolve():
|
|
10
|
+
raise ValueError("输入和输出不能相同")
|
|
11
|
+
image = read_image(input_path)
|
|
12
|
+
placement = estimate_transform(image,layout=layout)
|
|
13
|
+
if placement is None:
|
|
14
|
+
return False
|
|
15
|
+
write_dds(output_path, render(image,placement,layout=layout), encoder=encoder,
|
|
16
|
+
encoder_path=encoder_path, overwrite=overwrite)
|
|
17
|
+
return True
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Arguments for single-image processing."""
|
|
2
|
+
import argparse
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from .config import load_layout
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def arguments(argv=None):
|
|
9
|
+
parser = argparse.ArgumentParser()
|
|
10
|
+
parser.add_argument("mode", choices=("auto", "manual"))
|
|
11
|
+
parser.add_argument("--input", required=True, type=Path)
|
|
12
|
+
parser.add_argument("--output", required=True, type=Path)
|
|
13
|
+
parser.add_argument("--encoder", required=True, choices=("nvtt", "texconv"))
|
|
14
|
+
parser.add_argument("--encoder-path", required=True, type=Path)
|
|
15
|
+
parser.add_argument("--overwrite", action="store_true")
|
|
16
|
+
parser.add_argument("--model-cache", type=Path)
|
|
17
|
+
parser.add_argument("--layout", type=Path, help="JSON canvas and composition settings")
|
|
18
|
+
args = parser.parse_args(argv)
|
|
19
|
+
if args.model_cache:
|
|
20
|
+
os.environ["HF_HUB_CACHE"] = str(args.model_cache.resolve())
|
|
21
|
+
os.environ.setdefault("HF_HUB_OFFLINE", "1")
|
|
22
|
+
return args
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main(argv=None):
|
|
26
|
+
args = arguments(argv=argv)
|
|
27
|
+
if args.mode == "auto":
|
|
28
|
+
from .auto import prepare
|
|
29
|
+
else:
|
|
30
|
+
from .manual import prepare
|
|
31
|
+
try:
|
|
32
|
+
saved = prepare(args.input, args.output, encoder=args.encoder,
|
|
33
|
+
encoder_path=args.encoder_path, layout=load_layout(args.layout), overwrite=args.overwrite)
|
|
34
|
+
print("已保存" if saved else "需手动处理或已取消")
|
|
35
|
+
return 0 if saved else 2
|
|
36
|
+
except Exception as exc:
|
|
37
|
+
print(f"失败:{exc}")
|
|
38
|
+
return 1
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Canvas and composition settings shared by automatic and manual processing."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class Layout:
|
|
9
|
+
canvas: tuple = (1024, 1024)
|
|
10
|
+
anchor: tuple = (512, 175)
|
|
11
|
+
face_size: tuple = (95, 92)
|
|
12
|
+
anchor_x: tuple = (492, 546)
|
|
13
|
+
anchor_y: tuple = (125, 258)
|
|
14
|
+
margin: float = 2
|
|
15
|
+
alpha_core: int = 16
|
|
16
|
+
face_threshold: float = 0.55
|
|
17
|
+
|
|
18
|
+
def __post_init__(self):
|
|
19
|
+
for name in ("canvas","anchor","face_size","anchor_x","anchor_y"):
|
|
20
|
+
value = tuple(getattr(self,name))
|
|
21
|
+
if len(value) != 2:
|
|
22
|
+
raise ValueError(f"{name} must contain two values")
|
|
23
|
+
object.__setattr__(self,name,value)
|
|
24
|
+
if any(not isinstance(v, int) or v <= 2*self.margin for v in self.canvas) or self.margin < 0:
|
|
25
|
+
raise ValueError("画布宽高必须为正整数,且大于两侧边距")
|
|
26
|
+
if min(self.face_size) <= 0 or not 1 <= self.alpha_core <= 255 or not 0 <= self.face_threshold <= 1:
|
|
27
|
+
raise ValueError("脸部尺寸、Alpha 阈值或置信度无效")
|
|
28
|
+
for size, anchor, (lo, hi) in zip(self.canvas, self.anchor, (self.anchor_x, self.anchor_y)):
|
|
29
|
+
if not self.margin <= lo <= anchor <= hi <= size-self.margin or hi <= self.margin or lo >= size-self.margin:
|
|
30
|
+
raise ValueError("锚点和活动范围必须位于画布内,并为两侧留出空间")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_layout(path=None):
|
|
34
|
+
return Layout(**json.loads(Path(path).read_text(encoding="utf-8-sig"))) if path else Layout()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Encode via an explicitly supplied executable, then replace the destination."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import tempfile
|
|
4
|
+
from .encoders import ENCODERS
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def write_dds(path, image, *, encoder, encoder_path, overwrite=False):
|
|
8
|
+
path, executable = Path(path), Path(encoder_path).resolve()
|
|
9
|
+
if encoder not in ENCODERS:
|
|
10
|
+
raise ValueError("encoder 必须是 nvtt 或 texconv")
|
|
11
|
+
if not executable.is_file():
|
|
12
|
+
raise FileNotFoundError(executable)
|
|
13
|
+
if path.suffix.lower() != ".dds":
|
|
14
|
+
raise ValueError("输出必须是 .dds")
|
|
15
|
+
if path.exists() and not overwrite:
|
|
16
|
+
raise FileExistsError(path)
|
|
17
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
with tempfile.TemporaryDirectory(dir=path.parent, prefix=".dds-") as folder:
|
|
19
|
+
folder = Path(folder)
|
|
20
|
+
source, output = folder / "image.png", folder / "image.dds"
|
|
21
|
+
image.save(source, format="PNG")
|
|
22
|
+
ENCODERS[encoder](source, output, executable=executable)
|
|
23
|
+
if not output.is_file():
|
|
24
|
+
raise RuntimeError("编码器没有生成 DDS")
|
|
25
|
+
# Commit only after encoding succeeds; failed encoding leaves the old DDS.
|
|
26
|
+
output.replace(path)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""NVIDIA Texture Tools adapter for straight-alpha, single-level BC3."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def encode(source_png, output_dds, *, executable):
|
|
8
|
+
command = [str(executable), str(source_png), "--format", "bc3", "--quality", "production",
|
|
9
|
+
"--no-mips", "--max-mip-count", "1", "--output", str(output_dds)]
|
|
10
|
+
result = subprocess.run(command, capture_output=True, timeout=120,
|
|
11
|
+
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
|
|
12
|
+
if result.returncode:
|
|
13
|
+
message = (result.stdout + result.stderr).decode("utf-8", errors="replace")
|
|
14
|
+
raise OSError(f"nvtt 编码失败(退出码 {result.returncode}):{message[-2000:]}")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""DirectXTex adapter for legacy-header, single-level BC3."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import subprocess
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def encode(source_png, output_dds, *, executable):
|
|
9
|
+
source_png, output_dds = Path(source_png), Path(output_dds)
|
|
10
|
+
command = [str(executable), "-nologo", "-f", "BC3_UNORM", "-m", "1", "-dx9", "-y",
|
|
11
|
+
"-o", str(output_dds.parent), str(source_png)]
|
|
12
|
+
result = subprocess.run(command, capture_output=True, timeout=120,
|
|
13
|
+
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
|
|
14
|
+
if result.returncode:
|
|
15
|
+
message = (result.stdout + result.stderr).decode("utf-8", errors="replace")
|
|
16
|
+
raise OSError(f"texconv 编码失败(退出码 {result.returncode}):{message[-2000:]}")
|
|
17
|
+
generated = output_dds.parent / (source_png.stem + ".dds")
|
|
18
|
+
if generated != output_dds:
|
|
19
|
+
generated.replace(output_dds)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Manual composition with numeric placement, view navigation and guides."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from PIL.ImageQt import ImageQt
|
|
4
|
+
from PySide6.QtCore import QRectF, Qt, Signal
|
|
5
|
+
from PySide6.QtGui import QColor, QKeySequence, QPainter, QPen, QPixmap, QShortcut
|
|
6
|
+
from PySide6.QtWidgets import (QApplication, QCheckBox, QDialog, QDoubleSpinBox,
|
|
7
|
+
QFormLayout, QGraphicsItem, QGraphicsPixmapItem, QGraphicsRectItem, QGraphicsScene,
|
|
8
|
+
QGraphicsView, QHBoxLayout, QLabel, QMessageBox, QPushButton, QVBoxLayout)
|
|
9
|
+
from .config import Layout
|
|
10
|
+
from .dds import write_dds
|
|
11
|
+
from .transform import Placement, centered, read_image, render
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PortraitView(QGraphicsView):
|
|
15
|
+
placement_changed = Signal(object)
|
|
16
|
+
|
|
17
|
+
def __init__(self, image, *, layout=Layout(), parent=None):
|
|
18
|
+
super().__init__(parent)
|
|
19
|
+
self.config = layout
|
|
20
|
+
self._pan, self._view_changed = None, False
|
|
21
|
+
self.setScene(QGraphicsScene(self))
|
|
22
|
+
extent = max(*image.size, *layout.canvas) * 4
|
|
23
|
+
self.setSceneRect(-extent, -extent, extent*3, extent*3)
|
|
24
|
+
self.setBackgroundBrush(QColor("#444444"))
|
|
25
|
+
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
26
|
+
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
27
|
+
self.setTransformationAnchor(QGraphicsView.ViewportAnchor.NoAnchor)
|
|
28
|
+
self.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
|
|
29
|
+
self.item = QGraphicsPixmapItem(QPixmap.fromImage(ImageQt(image)))
|
|
30
|
+
self.item.setTransformationMode(Qt.TransformationMode.SmoothTransformation)
|
|
31
|
+
self.item.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable)
|
|
32
|
+
self.scene().addItem(self.item)
|
|
33
|
+
self.outline(QRectF(0,0,*layout.canvas), "white")
|
|
34
|
+
ax, ay = layout.anchor
|
|
35
|
+
fw, fh = layout.face_size
|
|
36
|
+
left, top, right, bottom = image.getchannel("A").getbbox() or (0,0,*image.size)
|
|
37
|
+
self.guides = [
|
|
38
|
+
self.outline(QRectF(ax-fw/2,ay-fh/2,fw,fh), "#e89b16"),
|
|
39
|
+
self.outline(QRectF(0,0,*image.size), "#45c9ed", self.item),
|
|
40
|
+
self.outline(QRectF(left,top,right-left,bottom-top), "#b58aee", self.item),
|
|
41
|
+
]
|
|
42
|
+
self.set_placement(centered(image, layout=layout))
|
|
43
|
+
|
|
44
|
+
def outline(self, rect, color, parent=None):
|
|
45
|
+
item = QGraphicsRectItem(rect, parent)
|
|
46
|
+
pen = QPen(QColor(color))
|
|
47
|
+
pen.setCosmetic(True)
|
|
48
|
+
item.setPen(pen)
|
|
49
|
+
item.setAcceptedMouseButtons(Qt.MouseButton.NoButton)
|
|
50
|
+
if parent is None:
|
|
51
|
+
self.scene().addItem(item)
|
|
52
|
+
item.setZValue(1)
|
|
53
|
+
return item
|
|
54
|
+
|
|
55
|
+
def fit_view(self):
|
|
56
|
+
self.fitInView(QRectF(0,0,*self.config.canvas).adjusted(-20,-20,20,20),
|
|
57
|
+
Qt.AspectRatioMode.KeepAspectRatio)
|
|
58
|
+
self._view_changed = False
|
|
59
|
+
|
|
60
|
+
def resizeEvent(self, event):
|
|
61
|
+
super().resizeEvent(event)
|
|
62
|
+
if not self._view_changed:
|
|
63
|
+
self.fit_view()
|
|
64
|
+
|
|
65
|
+
def wheelEvent(self, event):
|
|
66
|
+
point = self.mapToScene(event.position().toPoint())
|
|
67
|
+
factor = 1.12 ** max(-5, min(5, event.angleDelta().y()/120))
|
|
68
|
+
if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
|
69
|
+
old = self.transform().m11()
|
|
70
|
+
factor = max(0.02,min(32,old*factor))/old
|
|
71
|
+
self.scale(factor,factor)
|
|
72
|
+
moved = self.mapToScene(event.position().toPoint())
|
|
73
|
+
self.translate(moved.x()-point.x(),moved.y()-point.y())
|
|
74
|
+
self._view_changed = True
|
|
75
|
+
else:
|
|
76
|
+
p = self.placement()
|
|
77
|
+
scale = max(0.00001,min(10000,p.scale*factor))
|
|
78
|
+
self.set_placement(Placement(scale,point.x()+(p.x-point.x())*scale/p.scale,
|
|
79
|
+
point.y()+(p.y-point.y())*scale/p.scale))
|
|
80
|
+
event.accept()
|
|
81
|
+
|
|
82
|
+
def mousePressEvent(self, event):
|
|
83
|
+
if event.button() in (Qt.MouseButton.MiddleButton,Qt.MouseButton.RightButton):
|
|
84
|
+
self._pan = event.position().toPoint()
|
|
85
|
+
self.setCursor(Qt.CursorShape.ClosedHandCursor)
|
|
86
|
+
event.accept()
|
|
87
|
+
else:
|
|
88
|
+
super().mousePressEvent(event)
|
|
89
|
+
|
|
90
|
+
def mouseMoveEvent(self, event):
|
|
91
|
+
if self._pan is not None:
|
|
92
|
+
current = event.position().toPoint()
|
|
93
|
+
delta = current-self._pan
|
|
94
|
+
self.horizontalScrollBar().setValue(self.horizontalScrollBar().value()-delta.x())
|
|
95
|
+
self.verticalScrollBar().setValue(self.verticalScrollBar().value()-delta.y())
|
|
96
|
+
self._pan, self._view_changed = current, True
|
|
97
|
+
event.accept()
|
|
98
|
+
else:
|
|
99
|
+
super().mouseMoveEvent(event)
|
|
100
|
+
self.placement_changed.emit(self.placement())
|
|
101
|
+
|
|
102
|
+
def mouseReleaseEvent(self, event):
|
|
103
|
+
if self._pan is not None and event.button() in (Qt.MouseButton.MiddleButton,Qt.MouseButton.RightButton):
|
|
104
|
+
self._pan = None
|
|
105
|
+
self.unsetCursor()
|
|
106
|
+
event.accept()
|
|
107
|
+
else:
|
|
108
|
+
super().mouseReleaseEvent(event)
|
|
109
|
+
|
|
110
|
+
def set_placement(self, placement):
|
|
111
|
+
self.item.setScale(placement.scale)
|
|
112
|
+
self.item.setPos(placement.x,placement.y)
|
|
113
|
+
self.placement_changed.emit(placement)
|
|
114
|
+
|
|
115
|
+
def placement(self):
|
|
116
|
+
return Placement(self.item.scale(),self.item.x(),self.item.y())
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class PortraitEditor(QDialog):
|
|
120
|
+
def __init__(self, image, output, *, encoder, encoder_path, layout=Layout(), overwrite=False):
|
|
121
|
+
super().__init__()
|
|
122
|
+
self.image, self.output, self.config = image, output, layout
|
|
123
|
+
self.encoder, self.encoder_path, self.overwrite = encoder, encoder_path, overwrite
|
|
124
|
+
self.saved = False
|
|
125
|
+
self.setWindowTitle("立绘排版")
|
|
126
|
+
self.resize(1000,820)
|
|
127
|
+
window = QVBoxLayout(self)
|
|
128
|
+
body = QHBoxLayout()
|
|
129
|
+
window.addLayout(body)
|
|
130
|
+
self.view = PortraitView(image,layout=layout)
|
|
131
|
+
body.addWidget(self.view,1)
|
|
132
|
+
side = QVBoxLayout()
|
|
133
|
+
body.addLayout(side)
|
|
134
|
+
side.addWidget(QLabel(f"输出 {layout.canvas[0]} × {layout.canvas[1]}\n"
|
|
135
|
+
"左键拖动/滚轮缩放图片\nCtrl+滚轮缩放查看\n中键/右键移动视图"))
|
|
136
|
+
form = QFormLayout()
|
|
137
|
+
side.addLayout(form)
|
|
138
|
+
self.scale_box, self.x_box, self.y_box = QDoubleSpinBox(),QDoubleSpinBox(),QDoubleSpinBox()
|
|
139
|
+
for title, field, limits, suffix in (
|
|
140
|
+
("缩放",self.scale_box,(0.001,1000000)," %"),
|
|
141
|
+
("左上角 X",self.x_box,(-10000000,10000000)," px"),
|
|
142
|
+
("左上角 Y",self.y_box,(-10000000,10000000)," px")):
|
|
143
|
+
field.setRange(*limits)
|
|
144
|
+
field.setDecimals(3)
|
|
145
|
+
field.setSuffix(suffix)
|
|
146
|
+
field.setKeyboardTracking(False)
|
|
147
|
+
field.valueChanged.connect(self.edit_transform)
|
|
148
|
+
form.addRow(title,field)
|
|
149
|
+
self.view.placement_changed.connect(self.sync_controls)
|
|
150
|
+
self.sync_controls(self.view.placement())
|
|
151
|
+
guides = QCheckBox("显示辅助框")
|
|
152
|
+
guides.setChecked(True)
|
|
153
|
+
guides.toggled.connect(lambda visible: [item.setVisible(visible) for item in self.view.guides])
|
|
154
|
+
side.addWidget(guides)
|
|
155
|
+
side.addWidget(QLabel("白:输出画布\n橙:脸部参考\n青:原图边界\n紫:可见内容边界\n画布外内容会被裁切"))
|
|
156
|
+
for title, action in (
|
|
157
|
+
("查看完整画布",self.view.fit_view),
|
|
158
|
+
("立绘居中",lambda: self.view.set_placement(centered(image,layout=layout))),
|
|
159
|
+
("保存 DDS",self.save),("取消",self.reject)):
|
|
160
|
+
button = QPushButton(title)
|
|
161
|
+
button.setAutoDefault(False)
|
|
162
|
+
button.clicked.connect(action)
|
|
163
|
+
side.addWidget(button)
|
|
164
|
+
side.addStretch()
|
|
165
|
+
shortcut = QShortcut(QKeySequence.StandardKey.Save,self)
|
|
166
|
+
shortcut.activated.connect(self.save)
|
|
167
|
+
|
|
168
|
+
def sync_controls(self, placement):
|
|
169
|
+
for field, value in ((self.scale_box,placement.scale*100),
|
|
170
|
+
(self.x_box,placement.x),(self.y_box,placement.y)):
|
|
171
|
+
field.blockSignals(True)
|
|
172
|
+
field.setValue(value)
|
|
173
|
+
field.blockSignals(False)
|
|
174
|
+
|
|
175
|
+
def edit_transform(self):
|
|
176
|
+
self.view.set_placement(Placement(self.scale_box.value()/100,self.x_box.value(),self.y_box.value()))
|
|
177
|
+
|
|
178
|
+
def save(self):
|
|
179
|
+
try:
|
|
180
|
+
write_dds(self.output,render(self.image,self.view.placement(),layout=self.config),
|
|
181
|
+
encoder=self.encoder,encoder_path=self.encoder_path,overwrite=self.overwrite)
|
|
182
|
+
except Exception as exc:
|
|
183
|
+
QMessageBox.warning(self,"保存失败",str(exc))
|
|
184
|
+
return
|
|
185
|
+
self.saved = True
|
|
186
|
+
self.accept()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def prepare(input_path, output_path, *, encoder, encoder_path, layout=Layout(), overwrite=False):
|
|
190
|
+
if Path(input_path).resolve() == Path(output_path).resolve():
|
|
191
|
+
raise ValueError("输入和输出不能相同")
|
|
192
|
+
if Path(output_path).exists() and not overwrite:
|
|
193
|
+
raise FileExistsError(output_path)
|
|
194
|
+
image = read_image(input_path)
|
|
195
|
+
app = QApplication.instance() or QApplication([])
|
|
196
|
+
editor = PortraitEditor(image,output_path,encoder=encoder,encoder_path=encoder_path,
|
|
197
|
+
layout=layout,overwrite=overwrite)
|
|
198
|
+
editor.exec()
|
|
199
|
+
return editor.saved
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Read, compose and render a portrait on a configured canvas."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
import math
|
|
4
|
+
from PIL import Image, ImageOps
|
|
5
|
+
|
|
6
|
+
from .config import Layout
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Placement:
|
|
11
|
+
scale: float
|
|
12
|
+
x: float
|
|
13
|
+
y: float
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def read_image(path):
|
|
17
|
+
with Image.open(path) as image:
|
|
18
|
+
return ImageOps.exif_transpose(image).convert("RGBA")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def centered(image, *, layout=Layout()):
|
|
22
|
+
width, height = layout.canvas
|
|
23
|
+
margin = layout.margin
|
|
24
|
+
left, top, right, bottom = image.getchannel("A").getbbox() or (0, 0, *image.size)
|
|
25
|
+
scale = min((width-2*margin)/(right-left),(height-2*margin)/(bottom-top))
|
|
26
|
+
return Placement(scale,width/2-(left+right)*scale/2,height/2-(top+bottom)*scale/2)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def render(image, placement, *, layout=Layout()):
|
|
30
|
+
s, x, y = placement.scale, placement.x, placement.y
|
|
31
|
+
if s <= 0 or not all(map(math.isfinite, (s, x, y))):
|
|
32
|
+
raise ValueError("缩放必须为正数,位置必须为有限数值")
|
|
33
|
+
return image.convert("RGBa").transform(
|
|
34
|
+
layout.canvas, Image.Transform.AFFINE, (1/s, 0, -x/s, 0, 1/s, -y/s),
|
|
35
|
+
resample=Image.Resampling.BICUBIC, fillcolor=(0, 0, 0, 0)).convert("RGBA")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def detect_faces(image):
|
|
39
|
+
# Import the runtime first so imgutils cannot silently install it.
|
|
40
|
+
import onnxruntime as ort
|
|
41
|
+
if "CUDAExecutionProvider" in ort.get_available_providers() and hasattr(ort, "preload_dlls"):
|
|
42
|
+
ort.preload_dlls()
|
|
43
|
+
from imgutils.detect import detect_faces as detect
|
|
44
|
+
return detect(image, model_name="face_detect_v1.4_s", conf_threshold=0.25, iou_threshold=0.7)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def estimate_transform(image, *, layout=Layout()):
|
|
48
|
+
"""Return a placement, or None when the image needs manual composition."""
|
|
49
|
+
bounds = image.getchannel("A").point(lambda a: 255 if a >= layout.alpha_core else 0).getbbox()
|
|
50
|
+
if bounds is None:
|
|
51
|
+
return None
|
|
52
|
+
rgb = Image.alpha_composite(Image.new("RGBA", image.size, "white"), image).convert("RGB")
|
|
53
|
+
faces = detect_faces(rgb)
|
|
54
|
+
if not faces:
|
|
55
|
+
return None
|
|
56
|
+
def area(face):
|
|
57
|
+
x0, y0, x1, y1 = face[0]
|
|
58
|
+
return (x1 - x0) * (y1 - y0)
|
|
59
|
+
candidates = [f for f in faces if f[2] >= 0.5] or faces
|
|
60
|
+
face = max(candidates, key=lambda f: f[2] * math.sqrt(area(f)))
|
|
61
|
+
if face[2] < layout.face_threshold or any(f is not face and f[2] >= 0.6 and area(f) >= area(face)*0.5 for f in faces):
|
|
62
|
+
return None
|
|
63
|
+
x0, y0, x1, y1 = face[0]
|
|
64
|
+
cx, cy = (x0+x1)/2, (y0+y1)/2
|
|
65
|
+
left, top, right, bottom = bounds
|
|
66
|
+
xr, yr = layout.anchor_x, layout.anchor_y
|
|
67
|
+
width, height = layout.canvas
|
|
68
|
+
margin = layout.margin
|
|
69
|
+
limits = [math.sqrt(math.prod(layout.face_size)/area(face)), (width-2*margin)/(right-left), (height-2*margin)/(bottom-top)]
|
|
70
|
+
for distance, room in ((cx-left, xr[1]-margin), (right-cx, width-margin-xr[0]),
|
|
71
|
+
(cy-top, yr[1]-margin), (bottom-cy, height-margin-yr[0])):
|
|
72
|
+
if distance > 0:
|
|
73
|
+
limits.append(room/distance)
|
|
74
|
+
scale = min(limits)
|
|
75
|
+
ax = min(max(layout.anchor[0], xr[0], margin+(cx-left)*scale), xr[1], width-margin-(right-cx)*scale)
|
|
76
|
+
ay = min(max(layout.anchor[1], yr[0], margin+(cy-top)*scale), yr[1], height-margin-(bottom-cy)*scale)
|
|
77
|
+
return Placement(scale, ax-cx*scale, ay-cy*scale)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
# 直接从源码运行时使用本机项目路径,按需修改。
|
|
7
|
+
sys.path.insert(0, r"E:\Modding\anime_core_lib_portrait_tool")
|
|
8
|
+
from anime_core_lib_portrait_tool.auto import prepare as automatic
|
|
9
|
+
from anime_core_lib_portrait_tool import load_layout
|
|
10
|
+
|
|
11
|
+
CSV = Path(r"E:\Modding\anime_core_lib_portrait_tool\examples\jobs.csv")
|
|
12
|
+
# 两种处理方式共用布局;布局文件在上述 CSV 旁,编码器路径按本机安装位置修改。
|
|
13
|
+
OPTIONS = dict(layout=load_layout(CSV.with_name("layout.json")), encoder="nvtt",
|
|
14
|
+
encoder_path=r"C:\Program Files\NVIDIA Corporation\NVIDIA Texture Tools\nvtt_export.exe")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run_batch(csv_path=CSV):
|
|
18
|
+
csv_path = Path(csv_path)
|
|
19
|
+
# CSV 可带 UTF-8 BOM,列名为 input、output;路径相对 CSV 所在目录。
|
|
20
|
+
# 例如:images/source.png,output/result.dds
|
|
21
|
+
with csv_path.open(encoding="utf-8-sig", newline="") as stream:
|
|
22
|
+
jobs = [(csv_path.parent / row["input"], csv_path.parent / row["output"])
|
|
23
|
+
for row in csv.DictReader(stream)]
|
|
24
|
+
pending, complete = [], True
|
|
25
|
+
for source, output in jobs:
|
|
26
|
+
# 重跑时只处理尚未生成输出的任务。
|
|
27
|
+
if output.exists():
|
|
28
|
+
print(f"跳过:{output}")
|
|
29
|
+
continue
|
|
30
|
+
try:
|
|
31
|
+
# 无可靠主脸时加入人工队列,等全部自动任务结束后再处理。
|
|
32
|
+
if not automatic(source, output, **OPTIONS):
|
|
33
|
+
pending.append((source, output))
|
|
34
|
+
else:
|
|
35
|
+
print(f"完成:{output}")
|
|
36
|
+
except Exception as exc:
|
|
37
|
+
# 读取、模型或编码异常只记录失败,不转入人工队列。
|
|
38
|
+
print(f"失败:{source}:{exc}")
|
|
39
|
+
complete = False
|
|
40
|
+
for source, output in pending:
|
|
41
|
+
# 只有需要人工处理时才加载 GUI 依赖。
|
|
42
|
+
from anime_core_lib_portrait_tool.manual import prepare as manual
|
|
43
|
+
try:
|
|
44
|
+
if not manual(source, output, **OPTIONS):
|
|
45
|
+
# 用户取消时停止后续人工任务。
|
|
46
|
+
return False
|
|
47
|
+
print(f"完成:{output}")
|
|
48
|
+
except Exception as exc:
|
|
49
|
+
print(f"失败:{source}:{exc}")
|
|
50
|
+
complete = False
|
|
51
|
+
return complete
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
# 在项目目录执行 python -X utf8 examples/batch.py。
|
|
56
|
+
# 使用本机模型缓存;需要下载模型时将 HF_HUB_OFFLINE 改为 "0"。
|
|
57
|
+
os.environ["HF_HUB_CACHE"] = r"E:\Modding\portraits\models"
|
|
58
|
+
os.environ["HF_HUB_OFFLINE"] = "1"
|
|
59
|
+
# 退出码:0 全部完成,1 有错误或取消。
|
|
60
|
+
raise SystemExit(0 if run_batch() else 1)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.33.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "anime-core-lib-portrait-tool"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Automatic and manual portrait composition and DDS export for Anime Core Lib"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{name = "cwlm"}]
|
|
14
|
+
dependencies = ["Pillow>=13.0.0"]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
auto = ["dghs-imgutils>=0.19.0", "onnxruntime>=1.31.1"]
|
|
18
|
+
gpu = ["dghs-imgutils>=0.19.0", "onnxruntime-gpu>=1.31.1"]
|
|
19
|
+
gui = ["PySide6-Essentials>=6.12.0"]
|
|
20
|
+
|
|
21
|
+
[project.scripts]
|
|
22
|
+
portrait = "anime_core_lib_portrait_tool.cli:main"
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Repository = "https://github.com/cwlm/anime_core_lib_portrait_tool"
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["anime_core_lib_portrait_tool"]
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.sdist]
|
|
31
|
+
include = ["/anime_core_lib_portrait_tool", "/examples", "/portrait.py", "/pyproject.toml", "/README.md", "/LICENSE"]
|