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.
@@ -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,4 @@
1
+ """Portrait composition and DDS encoding."""
2
+ from .transform import Placement, read_image, render, estimate_transform
3
+ from .dds import write_dds
4
+ from .config import Layout, load_layout
@@ -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,6 @@
1
+ """Small adapters for external DDS encoders; no executable discovery."""
2
+
3
+ from . import nvtt, texconv
4
+
5
+
6
+ ENCODERS = {"nvtt": nvtt.encode, "texconv": texconv.encode}
@@ -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,2 @@
1
+ input,output
2
+ images/source.png,output/result.dds
@@ -0,0 +1,23 @@
1
+ {
2
+ "canvas": [
3
+ 1024,
4
+ 1024
5
+ ],
6
+ "anchor": [
7
+ 512,
8
+ 175
9
+ ],
10
+ "face_size": [
11
+ 95,
12
+ 92
13
+ ],
14
+ "anchor_x": [
15
+ 492,
16
+ 546
17
+ ],
18
+ "anchor_y": [
19
+ 125,
20
+ 258
21
+ ],
22
+ "margin": 2
23
+ }
@@ -0,0 +1,6 @@
1
+ """Single-image script: python portrait.py auto|manual --help."""
2
+
3
+ from anime_core_lib_portrait_tool.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -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"]