amv-diffusion 2.1.0__py3-none-any.whl
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.
- amv_diffusion-2.1.0.dist-info/METADATA +180 -0
- amv_diffusion-2.1.0.dist-info/RECORD +39 -0
- amv_diffusion-2.1.0.dist-info/WHEEL +5 -0
- amv_diffusion-2.1.0.dist-info/entry_points.txt +7 -0
- amv_diffusion-2.1.0.dist-info/licenses/LICENSE +21 -0
- amv_diffusion-2.1.0.dist-info/top_level.txt +1 -0
- termux_diffusion/__init__.py +185 -0
- termux_diffusion/__main__.py +7 -0
- termux_diffusion/_version.py +3 -0
- termux_diffusion/adapter.py +41 -0
- termux_diffusion/canvas.py +364 -0
- termux_diffusion/cli.py +492 -0
- termux_diffusion/cluster.py +340 -0
- termux_diffusion/control/__init__.py +5 -0
- termux_diffusion/control/component.py +309 -0
- termux_diffusion/control/status.py +15 -0
- termux_diffusion/core.py +2936 -0
- termux_diffusion/data/bootstrap-manifest.json +15 -0
- termux_diffusion/data/prompt_cache/108a7149c4e70550.json +10 -0
- termux_diffusion/data/release-public-keys.json +10 -0
- termux_diffusion/data/validated-vulkan-profiles.json +298 -0
- termux_diffusion/detailer.py +283 -0
- termux_diffusion/doctor.py +128 -0
- termux_diffusion/downloader.py +111 -0
- termux_diffusion/engine.py +226 -0
- termux_diffusion/exceptions.py +158 -0
- termux_diffusion/hardware.py +985 -0
- termux_diffusion/hub.py +686 -0
- termux_diffusion/installer.py +499 -0
- termux_diffusion/locking.py +134 -0
- termux_diffusion/manifest.py +128 -0
- termux_diffusion/npu.py +267 -0
- termux_diffusion/platform.py +533 -0
- termux_diffusion/purger.py +145 -0
- termux_diffusion/py.typed +1 -0
- termux_diffusion/safety.py +150 -0
- termux_diffusion/selftest.py +178 -0
- termux_diffusion/web.py +3370 -0
- termux_diffusion/workflow.py +1345 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"release_manifest_urls": [
|
|
4
|
+
"https://raw.githubusercontent.com/uno-km/termux-diffusion/main/manifest.json"
|
|
5
|
+
],
|
|
6
|
+
"allowed_hosts": [
|
|
7
|
+
"raw.githubusercontent.com",
|
|
8
|
+
"github.com",
|
|
9
|
+
"objects.githubusercontent.com"
|
|
10
|
+
],
|
|
11
|
+
"trusted_key_ids": [
|
|
12
|
+
"release-key-2026-01"
|
|
13
|
+
],
|
|
14
|
+
"channel": "stable"
|
|
15
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"prompt": "A cinematic photo of a neon cybernetic tiger walking in Seoul street at night",
|
|
3
|
+
"hash": "108a7149c4e70550",
|
|
4
|
+
"text_encoder": "Qwen3-4B-Instruct-2507-Q2_K.gguf",
|
|
5
|
+
"target_model": "z_image_turbo-Q2_K.gguf",
|
|
6
|
+
"created_at": "2026-09-28T09:15:00Z",
|
|
7
|
+
"verified_devices": ["SM-G991N", "SM-A356N"],
|
|
8
|
+
"status": "verified",
|
|
9
|
+
"version": "1.7.0"
|
|
10
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.1.0",
|
|
3
|
+
"profiles": [
|
|
4
|
+
{
|
|
5
|
+
"device_model": "SM-S931N",
|
|
6
|
+
"device_aliases": [
|
|
7
|
+
"SM-S931",
|
|
8
|
+
"SM-S936",
|
|
9
|
+
"SM-S938",
|
|
10
|
+
"pa1q"
|
|
11
|
+
],
|
|
12
|
+
"soc": "SM8750",
|
|
13
|
+
"gpu": "Adreno (TM) 830",
|
|
14
|
+
"backend_profile": "adreno-vulkan",
|
|
15
|
+
"package_tag": "v1.3.1-vulkan-experimental",
|
|
16
|
+
"package_tarball": "termux-diffusion-vulkan-prebuilt-v1.3.1-android-arm64-adreno.tar.gz",
|
|
17
|
+
"package_sha256": "d1f0a2656a33d0929cfd3335e01feeabf9c3a1e34a0ae0eacc04ddb3701ece92",
|
|
18
|
+
"vulkan_api_version": "1.3.284",
|
|
19
|
+
"presets": {
|
|
20
|
+
"fast": {
|
|
21
|
+
"model_family": "sdxs",
|
|
22
|
+
"quantization": "Q8_0",
|
|
23
|
+
"width": 256,
|
|
24
|
+
"height": 256,
|
|
25
|
+
"steps": 1,
|
|
26
|
+
"sampler": "euler_a",
|
|
27
|
+
"cfg_scale": 1.0,
|
|
28
|
+
"backend": "vulkan",
|
|
29
|
+
"status": "verified"
|
|
30
|
+
},
|
|
31
|
+
"balanced": {
|
|
32
|
+
"model_family": "sdxs",
|
|
33
|
+
"quantization": "Q8_0",
|
|
34
|
+
"width": 512,
|
|
35
|
+
"height": 512,
|
|
36
|
+
"steps": 2,
|
|
37
|
+
"sampler": "euler_a",
|
|
38
|
+
"cfg_scale": 1.0,
|
|
39
|
+
"vae_tiling": true,
|
|
40
|
+
"backend": "vulkan",
|
|
41
|
+
"status": "verified"
|
|
42
|
+
},
|
|
43
|
+
"anime_experimental": {
|
|
44
|
+
"model_family": "dreamshaper",
|
|
45
|
+
"quantization": "Q4_0",
|
|
46
|
+
"width": 512,
|
|
47
|
+
"height": 512,
|
|
48
|
+
"steps": 6,
|
|
49
|
+
"sampler": "lcm",
|
|
50
|
+
"cfg_scale": 1.5,
|
|
51
|
+
"vae_tiling": true,
|
|
52
|
+
"backend": "vulkan",
|
|
53
|
+
"status": "verified_experimental"
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"blocked": [
|
|
57
|
+
{
|
|
58
|
+
"model_family": "realistic_vision",
|
|
59
|
+
"quantization": "Q4_K",
|
|
60
|
+
"backend": "vulkan",
|
|
61
|
+
"reason": "q4_k_vulkan_pipeline_creation_failed",
|
|
62
|
+
"status": "blocked_pipeline_creation"
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"model_family": "dreamshaper",
|
|
66
|
+
"backend": "cpu",
|
|
67
|
+
"reason": "all_white_output",
|
|
68
|
+
"status": "blocked_cpu_quality_failure"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"model_family": "realistic_vision",
|
|
72
|
+
"backend": "cpu",
|
|
73
|
+
"reason": "all_white_output",
|
|
74
|
+
"status": "blocked_cpu_quality_failure"
|
|
75
|
+
}
|
|
76
|
+
]
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"device_model": "SM-G991N",
|
|
80
|
+
"device_aliases": [
|
|
81
|
+
"SM-G991",
|
|
82
|
+
"SM-G996",
|
|
83
|
+
"SM-G998",
|
|
84
|
+
"o1s"
|
|
85
|
+
],
|
|
86
|
+
"soc": "Exynos 2100",
|
|
87
|
+
"gpu": "Mali-G78",
|
|
88
|
+
"backend_profile": "mali-vulkan",
|
|
89
|
+
"package_tag": "v1.3.1-vulkan-mali-experimental",
|
|
90
|
+
"package_tarball": "termux-diffusion-vulkan-prebuilt-v1.3.1-android-arm64-mali-compat-v2.tar.gz",
|
|
91
|
+
"package_sha256": "65e4e305241b22385313e386afbcd12722061041280d00a44dfdc3ff23aa17b8",
|
|
92
|
+
"vulkan_api_version": "1.3",
|
|
93
|
+
"presets": {
|
|
94
|
+
"fast": {
|
|
95
|
+
"model_family": "sdxs",
|
|
96
|
+
"quantization": "Q8_0",
|
|
97
|
+
"width": 256,
|
|
98
|
+
"height": 256,
|
|
99
|
+
"steps": 1,
|
|
100
|
+
"sampler": "euler_a",
|
|
101
|
+
"cfg_scale": 1.0,
|
|
102
|
+
"backend": "vulkan",
|
|
103
|
+
"status": "verified"
|
|
104
|
+
},
|
|
105
|
+
"balanced": {
|
|
106
|
+
"model_family": "sdxs",
|
|
107
|
+
"quantization": "Q8_0",
|
|
108
|
+
"width": 512,
|
|
109
|
+
"height": 512,
|
|
110
|
+
"steps": 2,
|
|
111
|
+
"sampler": "euler_a",
|
|
112
|
+
"cfg_scale": 1.0,
|
|
113
|
+
"vae_tiling": true,
|
|
114
|
+
"backend": "vulkan",
|
|
115
|
+
"status": "verified"
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
"blocked": [
|
|
119
|
+
{
|
|
120
|
+
"model_family": "dreamshaper",
|
|
121
|
+
"backend": "cpu",
|
|
122
|
+
"reason": "all_white_output",
|
|
123
|
+
"status": "blocked_cpu_quality_failure"
|
|
124
|
+
}
|
|
125
|
+
]
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
"device_model": "SM-G981N",
|
|
129
|
+
"device_aliases": [
|
|
130
|
+
"SM-G981B",
|
|
131
|
+
"SM-G986B",
|
|
132
|
+
"SM-G988B",
|
|
133
|
+
"hub1"
|
|
134
|
+
],
|
|
135
|
+
"soc": "Exynos 990",
|
|
136
|
+
"gpu": "Mali-G77",
|
|
137
|
+
"backend_profile": "mali-vulkan",
|
|
138
|
+
"package_tag": "v1.3.1-vulkan-mali-experimental",
|
|
139
|
+
"package_tarball": "termux-diffusion-vulkan-prebuilt-v1.3.1-android-arm64-mali-compat-v2.tar.gz",
|
|
140
|
+
"package_sha256": "65e4e305241b22385313e386afbcd12722061041280d00a44dfdc3ff23aa17b8",
|
|
141
|
+
"vulkan_api_version": "1.1",
|
|
142
|
+
"presets": {
|
|
143
|
+
"fast": {
|
|
144
|
+
"model_family": "sdxs",
|
|
145
|
+
"quantization": "Q8_0",
|
|
146
|
+
"width": 256,
|
|
147
|
+
"height": 256,
|
|
148
|
+
"steps": 1,
|
|
149
|
+
"sampler": "euler_a",
|
|
150
|
+
"cfg_scale": 1.0,
|
|
151
|
+
"backend": "vulkan",
|
|
152
|
+
"status": "verified"
|
|
153
|
+
},
|
|
154
|
+
"balanced": {
|
|
155
|
+
"model_family": "sdxs",
|
|
156
|
+
"quantization": "Q8_0",
|
|
157
|
+
"width": 512,
|
|
158
|
+
"height": 512,
|
|
159
|
+
"steps": 2,
|
|
160
|
+
"sampler": "euler_a",
|
|
161
|
+
"cfg_scale": 1.0,
|
|
162
|
+
"vae_tiling": true,
|
|
163
|
+
"backend": "vulkan",
|
|
164
|
+
"status": "verified"
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
"blocked": []
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
"device_model": "SM-G986N",
|
|
171
|
+
"device_aliases": [
|
|
172
|
+
"SM-G981",
|
|
173
|
+
"SM-G986",
|
|
174
|
+
"SM-G988",
|
|
175
|
+
"SM-G781",
|
|
176
|
+
"r8q",
|
|
177
|
+
"z3q",
|
|
178
|
+
"kona",
|
|
179
|
+
"SM8250"
|
|
180
|
+
],
|
|
181
|
+
"soc": "Snapdragon 865",
|
|
182
|
+
"gpu": "Adreno (TM) 650",
|
|
183
|
+
"backend_profile": "cpu-neon",
|
|
184
|
+
"vulkan_api_version": "1.1",
|
|
185
|
+
"presets": {
|
|
186
|
+
"fast": {
|
|
187
|
+
"model_family": "sdxs",
|
|
188
|
+
"quantization": "Q8_0",
|
|
189
|
+
"width": 256,
|
|
190
|
+
"height": 256,
|
|
191
|
+
"steps": 1,
|
|
192
|
+
"sampler": "euler_a",
|
|
193
|
+
"cfg_scale": 1.0,
|
|
194
|
+
"backend": "cpu",
|
|
195
|
+
"status": "verified"
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
"blocked": [
|
|
199
|
+
{
|
|
200
|
+
"model_family": "",
|
|
201
|
+
"backend": "vulkan",
|
|
202
|
+
"reason": "adreno_650_missing_storage_buffer_8bit_access",
|
|
203
|
+
"status": "blocked_hardware_feature_missing"
|
|
204
|
+
}
|
|
205
|
+
]
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
"device_model": "SM-A356N",
|
|
209
|
+
"device_aliases": [
|
|
210
|
+
"SM-A356",
|
|
211
|
+
"a35x",
|
|
212
|
+
"a35xks"
|
|
213
|
+
],
|
|
214
|
+
"soc": "Exynos 1380",
|
|
215
|
+
"gpu": "Mali-G68",
|
|
216
|
+
"backend_profile": "mali-vulkan",
|
|
217
|
+
"package_tag": "v1.3.1-vulkan-mali-experimental",
|
|
218
|
+
"package_tarball": "termux-diffusion-vulkan-prebuilt-v1.3.1-android-arm64-mali-compat-v2.tar.gz",
|
|
219
|
+
"package_sha256": "65e4e305241b22385313e386afbcd12722061041280d00a44dfdc3ff23aa17b8",
|
|
220
|
+
"vulkan_api_version": "1.3",
|
|
221
|
+
"presets": {
|
|
222
|
+
"fast": {
|
|
223
|
+
"model_family": "sdxs",
|
|
224
|
+
"quantization": "Q8_0",
|
|
225
|
+
"width": 256,
|
|
226
|
+
"height": 256,
|
|
227
|
+
"steps": 1,
|
|
228
|
+
"sampler": "euler_a",
|
|
229
|
+
"cfg_scale": 1.0,
|
|
230
|
+
"backend": "vulkan",
|
|
231
|
+
"status": "verified",
|
|
232
|
+
"auto_activation": true
|
|
233
|
+
},
|
|
234
|
+
"balanced": {
|
|
235
|
+
"model_family": "sdxs",
|
|
236
|
+
"quantization": "Q8_0",
|
|
237
|
+
"width": 512,
|
|
238
|
+
"height": 512,
|
|
239
|
+
"steps": 2,
|
|
240
|
+
"sampler": "euler_a",
|
|
241
|
+
"cfg_scale": 1.0,
|
|
242
|
+
"vae_tiling": true,
|
|
243
|
+
"backend": "vulkan",
|
|
244
|
+
"status": "pending_device_validation",
|
|
245
|
+
"auto_activation": false
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
"blocked": [],
|
|
249
|
+
"package_size_bytes": 56678669
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
"device_model": "SM-A536N",
|
|
253
|
+
"device_aliases": [
|
|
254
|
+
"SM-A536",
|
|
255
|
+
"SM-A536B",
|
|
256
|
+
"SM-A536U",
|
|
257
|
+
"a53x",
|
|
258
|
+
"a53xks"
|
|
259
|
+
],
|
|
260
|
+
"soc": "Exynos 1280",
|
|
261
|
+
"gpu": "Mali-G68",
|
|
262
|
+
"backend_profile": "mali-vulkan",
|
|
263
|
+
"package_tag": "v1.3.1-vulkan-mali-experimental",
|
|
264
|
+
"package_tarball": "termux-diffusion-vulkan-prebuilt-v1.3.1-android-arm64-mali-compat-v2.tar.gz",
|
|
265
|
+
"package_sha256": "65e4e305241b22385313e386afbcd12722061041280d00a44dfdc3ff23aa17b8",
|
|
266
|
+
"package_size_bytes": 56678669,
|
|
267
|
+
"vulkan_api_version": "1.3",
|
|
268
|
+
"presets": {
|
|
269
|
+
"fast": {
|
|
270
|
+
"model_family": "sdxs",
|
|
271
|
+
"quantization": "Q8_0",
|
|
272
|
+
"width": 256,
|
|
273
|
+
"height": 256,
|
|
274
|
+
"steps": 1,
|
|
275
|
+
"sampler": "euler_a",
|
|
276
|
+
"cfg_scale": 1.0,
|
|
277
|
+
"backend": "vulkan",
|
|
278
|
+
"status": "verified",
|
|
279
|
+
"auto_activation": true
|
|
280
|
+
},
|
|
281
|
+
"balanced": {
|
|
282
|
+
"model_family": "sdxs",
|
|
283
|
+
"quantization": "Q8_0",
|
|
284
|
+
"width": 512,
|
|
285
|
+
"height": 512,
|
|
286
|
+
"steps": 2,
|
|
287
|
+
"sampler": "euler_a",
|
|
288
|
+
"cfg_scale": 1.0,
|
|
289
|
+
"vae_tiling": true,
|
|
290
|
+
"backend": "vulkan",
|
|
291
|
+
"status": "pending_device_validation",
|
|
292
|
+
"auto_activation": false
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
"blocked": []
|
|
296
|
+
}
|
|
297
|
+
]
|
|
298
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
# Copyright (c) 2026 Uno Kim / AMEVA Foundation. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0 OR Commercial-License
|
|
3
|
+
"""Mobile FaceDetailer Module for Termux-Diffusion.
|
|
4
|
+
|
|
5
|
+
Brings ComfyUI Impact Pack / ADetailer capability to mobile on-device diffusion:
|
|
6
|
+
1. Detects facial bounding boxes in generated canvas using lightweight on-device vision.
|
|
7
|
+
2. Crops and upscales each face to 512x512.
|
|
8
|
+
3. Applies high-frequency localized Img2Img refinement (e.g., 2-4 steps, strength 0.35).
|
|
9
|
+
4. Blends refined facial features seamlessly back into the canvas with soft feathered alpha masks.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import tempfile
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
from PIL import Image, ImageDraw, ImageFilter
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("termux_diffusion.detailer")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def calculate_iou(box1: Tuple[int, int, int, int], box2: Tuple[int, int, int, int]) -> float:
|
|
25
|
+
"""Calculate Intersection over Union (IoU) between two bounding boxes (l, t, r, b)."""
|
|
26
|
+
l1, t1, r1, b1 = box1
|
|
27
|
+
l2, t2, r2, b2 = box2
|
|
28
|
+
|
|
29
|
+
inter_l = max(l1, l2)
|
|
30
|
+
inter_t = max(t1, t2)
|
|
31
|
+
inter_r = min(r1, r2)
|
|
32
|
+
inter_b = min(b1, b2)
|
|
33
|
+
|
|
34
|
+
inter_w = max(0, inter_r - inter_l)
|
|
35
|
+
inter_h = max(0, inter_b - inter_t)
|
|
36
|
+
inter_area = inter_w * inter_h
|
|
37
|
+
|
|
38
|
+
area1 = max(0, r1 - l1) * max(0, b1 - t1)
|
|
39
|
+
area2 = max(0, r2 - l2) * max(0, b2 - t2)
|
|
40
|
+
union_area = area1 + area2 - inter_area
|
|
41
|
+
|
|
42
|
+
if union_area <= 0:
|
|
43
|
+
return 0.0
|
|
44
|
+
return inter_area / union_area
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def non_max_suppression(
|
|
48
|
+
boxes: List[Tuple[int, int, int, int]],
|
|
49
|
+
iou_threshold: float = 0.35,
|
|
50
|
+
) -> List[Tuple[int, int, int, int]]:
|
|
51
|
+
"""Merges overlapping candidate bounding boxes using standard Non-Maximum Suppression."""
|
|
52
|
+
if not boxes:
|
|
53
|
+
return []
|
|
54
|
+
|
|
55
|
+
# Sort boxes by area descending
|
|
56
|
+
sorted_boxes = sorted(boxes, key=lambda b: (b[2] - b[0]) * (b[3] - b[1]), reverse=True)
|
|
57
|
+
selected: List[Tuple[int, int, int, int]] = []
|
|
58
|
+
|
|
59
|
+
for box in sorted_boxes:
|
|
60
|
+
should_keep = True
|
|
61
|
+
for sel in selected:
|
|
62
|
+
if calculate_iou(box, sel) > iou_threshold:
|
|
63
|
+
should_keep = False
|
|
64
|
+
break
|
|
65
|
+
if should_keep:
|
|
66
|
+
selected.append(box)
|
|
67
|
+
|
|
68
|
+
return selected
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def detect_faces_on_image(
|
|
72
|
+
image: Union[str, Path, Image.Image],
|
|
73
|
+
min_face_size: int = 28,
|
|
74
|
+
max_faces: int = 4,
|
|
75
|
+
) -> List[Tuple[int, int, int, int]]:
|
|
76
|
+
"""Detects face bounding boxes in an image using available on-device vision detectors.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
List of bounding boxes formatted as (left, top, right, bottom).
|
|
80
|
+
"""
|
|
81
|
+
if isinstance(image, (str, Path)):
|
|
82
|
+
pil_img = Image.open(str(image)).convert("RGB")
|
|
83
|
+
else:
|
|
84
|
+
pil_img = image.convert("RGB")
|
|
85
|
+
|
|
86
|
+
w, h = pil_img.size
|
|
87
|
+
np_img = np.array(pil_img)
|
|
88
|
+
candidate_boxes: List[Tuple[int, int, int, int]] = []
|
|
89
|
+
|
|
90
|
+
# Priority A: Check termux_vision (official AMEVA vision stack)
|
|
91
|
+
try:
|
|
92
|
+
import termux_vision
|
|
93
|
+
detections = termux_vision.detect_faces(np_img)
|
|
94
|
+
for det in detections:
|
|
95
|
+
bbox = getattr(det, "bbox", det)
|
|
96
|
+
l = getattr(bbox, "left", None)
|
|
97
|
+
t = getattr(bbox, "top", None)
|
|
98
|
+
r = getattr(bbox, "right", None)
|
|
99
|
+
b = getattr(bbox, "bottom", None)
|
|
100
|
+
if None not in (l, t, r, b):
|
|
101
|
+
if (r - l) >= min_face_size and (b - t) >= min_face_size:
|
|
102
|
+
candidate_boxes.append((int(l), int(t), int(r), int(b)))
|
|
103
|
+
except Exception as e:
|
|
104
|
+
logger.debug("termux_vision face detection skipped or failed: %s", e)
|
|
105
|
+
|
|
106
|
+
# Priority B: OpenCV Cascade / YuNet fallback if available
|
|
107
|
+
if not candidate_boxes:
|
|
108
|
+
try:
|
|
109
|
+
import cv2
|
|
110
|
+
gray = cv2.cvtColor(np_img, cv2.COLOR_RGB2GRAY)
|
|
111
|
+
cascade_path = getattr(cv2.data, "haarcascades", "") + "haarcascade_frontalface_default.xml"
|
|
112
|
+
if os.path.isfile(cascade_path):
|
|
113
|
+
face_cascade = cv2.CascadeClassifier(cascade_path)
|
|
114
|
+
faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=4, minSize=(min_face_size, min_face_size))
|
|
115
|
+
for (x, y, fw, fh) in faces:
|
|
116
|
+
candidate_boxes.append((int(x), int(y), int(x + fw), int(y + fh)))
|
|
117
|
+
except Exception as cv_err:
|
|
118
|
+
logger.debug("OpenCV face detection skipped or failed: %s", cv_err)
|
|
119
|
+
|
|
120
|
+
# Apply NMS to eliminate duplicate clustered candidate boxes
|
|
121
|
+
merged_boxes = non_max_suppression(candidate_boxes, iou_threshold=0.35)
|
|
122
|
+
|
|
123
|
+
# Filter boundary margin noise (false detections sticking directly to image borders)
|
|
124
|
+
filtered: List[Tuple[int, int, int, int]] = []
|
|
125
|
+
for (l, t, r, b) in merged_boxes:
|
|
126
|
+
bw, bh = r - l, b - t
|
|
127
|
+
if (l <= 2 or r >= w - 2) and (bw < 40 or bh < 40):
|
|
128
|
+
continue
|
|
129
|
+
filtered.append((l, t, r, b))
|
|
130
|
+
|
|
131
|
+
# Prioritize largest face regions and cap to max_faces
|
|
132
|
+
filtered.sort(key=lambda bx: (bx[2] - bx[0]) * (bx[3] - bx[1]), reverse=True)
|
|
133
|
+
final_boxes = filtered[:max_faces] if max_faces > 0 else filtered
|
|
134
|
+
|
|
135
|
+
logger.info("[FaceDetailer] Detected %d face region(s) in %dx%d image.", len(final_boxes), w, h)
|
|
136
|
+
return final_boxes
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def create_feathered_mask(size: Tuple[int, int], blur_radius: float = 12.0) -> Image.Image:
|
|
140
|
+
"""Generates an elliptical alpha mask with smooth feathered Gaussian edges."""
|
|
141
|
+
w, h = size
|
|
142
|
+
mask = Image.new("L", (w, h), 0)
|
|
143
|
+
draw = ImageDraw.Draw(mask)
|
|
144
|
+
|
|
145
|
+
# Inset by margin to allow smooth edge feathering
|
|
146
|
+
margin_x = max(2, int(w * 0.08))
|
|
147
|
+
margin_y = max(2, int(h * 0.08))
|
|
148
|
+
draw.ellipse([margin_x, margin_y, w - margin_x, h - margin_y], fill=255)
|
|
149
|
+
|
|
150
|
+
if blur_radius > 0:
|
|
151
|
+
mask = mask.filter(ImageFilter.GaussianBlur(blur_radius))
|
|
152
|
+
return mask
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def refine_face_regions(
|
|
156
|
+
image_path: Union[str, Path],
|
|
157
|
+
generator_func: Callable[..., Any],
|
|
158
|
+
prompt: str,
|
|
159
|
+
output_path: Optional[Union[str, Path]] = None,
|
|
160
|
+
strength: float = 0.35,
|
|
161
|
+
steps: int = 2,
|
|
162
|
+
padding_factor: float = 1.45,
|
|
163
|
+
device: str = "auto",
|
|
164
|
+
model: str = "turbo",
|
|
165
|
+
generator_kwargs: Optional[Dict[str, Any]] = None,
|
|
166
|
+
) -> Path:
|
|
167
|
+
"""Applies ComfyUI-style FaceDetailer: crops, refines with localized Img2Img, and blends back.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
image_path: Path to the base generated image.
|
|
171
|
+
generator_func: The termux_diffusion.core.generate callable.
|
|
172
|
+
prompt: Base generation prompt for contextual text conditioning.
|
|
173
|
+
output_path: Destination path for the detailed image (overwrites image_path if None).
|
|
174
|
+
strength: Denoising strength for localized face refinement (default: 0.35).
|
|
175
|
+
steps: Number of sampling steps for the localized face crop (default: 2).
|
|
176
|
+
padding_factor: Expansion factor around face bounding box (default: 1.45).
|
|
177
|
+
device: Target execution device ('auto', 'vulkan', 'cpu', etc.).
|
|
178
|
+
model: Model preset or path.
|
|
179
|
+
generator_kwargs: Additional kwargs passed to generator_func.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Path to the final detailed image.
|
|
183
|
+
"""
|
|
184
|
+
src_path = Path(image_path).resolve()
|
|
185
|
+
dest_path = Path(output_path).resolve() if output_path else src_path
|
|
186
|
+
|
|
187
|
+
if not src_path.is_file():
|
|
188
|
+
raise FileNotFoundError(f"[FaceDetailer] Source image file not found: {src_path}")
|
|
189
|
+
|
|
190
|
+
base_image = Image.open(src_path).convert("RGB")
|
|
191
|
+
orig_w, orig_h = base_image.size
|
|
192
|
+
|
|
193
|
+
boxes = detect_faces_on_image(base_image)
|
|
194
|
+
if not boxes:
|
|
195
|
+
logger.info("[FaceDetailer] No faces detected above threshold. Keeping base canvas intact.")
|
|
196
|
+
if dest_path != src_path:
|
|
197
|
+
base_image.save(dest_path)
|
|
198
|
+
return dest_path
|
|
199
|
+
|
|
200
|
+
working_canvas = base_image.copy()
|
|
201
|
+
extra_kwargs = dict(generator_kwargs or {})
|
|
202
|
+
|
|
203
|
+
# Strip detailer flags from recursive generator calls to prevent infinite loops
|
|
204
|
+
extra_kwargs.pop("detail_faces", None)
|
|
205
|
+
extra_kwargs.pop("export_gallery", None)
|
|
206
|
+
|
|
207
|
+
with tempfile.TemporaryDirectory(prefix="face_detailer_") as tmpdir:
|
|
208
|
+
tmp_dir_path = Path(tmpdir)
|
|
209
|
+
|
|
210
|
+
for idx, (l, t, r, b) in enumerate(boxes):
|
|
211
|
+
box_w = r - l
|
|
212
|
+
box_h = b - t
|
|
213
|
+
center_x = l + box_w // 2
|
|
214
|
+
center_y = t + box_h // 2
|
|
215
|
+
|
|
216
|
+
# Expand with padding to capture full facial structure, forehead, and chin
|
|
217
|
+
padded_dim = int(max(box_w, box_h) * padding_factor)
|
|
218
|
+
pad_l = max(0, center_x - padded_dim // 2)
|
|
219
|
+
pad_t = max(0, center_y - padded_dim // 2)
|
|
220
|
+
pad_r = min(orig_w, pad_l + padded_dim)
|
|
221
|
+
pad_b = min(orig_h, pad_t + padded_dim)
|
|
222
|
+
|
|
223
|
+
# Re-adjust left/top if clamped at right/bottom edges
|
|
224
|
+
actual_w = pad_r - pad_l
|
|
225
|
+
actual_h = pad_b - pad_t
|
|
226
|
+
if actual_w <= 16 or actual_h <= 16:
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
# 1. Crop face region
|
|
230
|
+
face_crop = working_canvas.crop((pad_l, pad_t, pad_r, pad_b))
|
|
231
|
+
|
|
232
|
+
# 2. Upscale to 512x512 canvas for high-resolution diffusion pass
|
|
233
|
+
face_upscaled = face_crop.resize((512, 512), Image.Resampling.LANCZOS)
|
|
234
|
+
crop_in_path = tmp_dir_path / f"face_{idx}_in.png"
|
|
235
|
+
crop_out_path = tmp_dir_path / f"face_{idx}_out.png"
|
|
236
|
+
face_upscaled.save(crop_in_path, "PNG")
|
|
237
|
+
|
|
238
|
+
# 3. Formulate face-specific prompt
|
|
239
|
+
face_prompt = (
|
|
240
|
+
f"extreme close-up portrait of face, {prompt}, "
|
|
241
|
+
f"hyperdetailed sharp eyes, natural skin micro-texture, authentic facial expression, photorealistic 8k"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
logger.info(
|
|
245
|
+
"[FaceDetailer] Refining face #%d/%d at [%d,%d,%d,%d] (upscaled 512x512, strength=%.2f, steps=%d)...",
|
|
246
|
+
idx + 1, len(boxes), pad_l, pad_t, pad_r, pad_b, strength, steps
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
# 4. Invoke localized Img2Img generation
|
|
251
|
+
generator_func(
|
|
252
|
+
prompt=face_prompt,
|
|
253
|
+
init_img=crop_in_path,
|
|
254
|
+
strength=strength,
|
|
255
|
+
steps=steps,
|
|
256
|
+
width=512,
|
|
257
|
+
height=512,
|
|
258
|
+
output=crop_out_path,
|
|
259
|
+
device=device,
|
|
260
|
+
model=model,
|
|
261
|
+
export_gallery=False,
|
|
262
|
+
**extra_kwargs,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
if crop_out_path.is_file():
|
|
266
|
+
refined_crop_512 = Image.open(crop_out_path).convert("RGB")
|
|
267
|
+
# 5. Downscale refined face back to native cropped size
|
|
268
|
+
refined_face = refined_crop_512.resize((actual_w, actual_h), Image.Resampling.LANCZOS)
|
|
269
|
+
|
|
270
|
+
# 6. Apply feathered alpha mask to seamlessly blend back without harsh borders
|
|
271
|
+
blur_r = max(4.0, actual_w * 0.12)
|
|
272
|
+
feather_mask = create_feathered_mask((actual_w, actual_h), blur_radius=blur_r)
|
|
273
|
+
working_canvas.paste(refined_face, (pad_l, pad_t), feather_mask)
|
|
274
|
+
logger.info("[FaceDetailer] Successfully blended refined face #%d into canvas.", idx + 1)
|
|
275
|
+
else:
|
|
276
|
+
logger.warning("[FaceDetailer] Refined face #%d output missing; preserving original crop.", idx + 1)
|
|
277
|
+
except Exception as ref_err:
|
|
278
|
+
logger.warning("[FaceDetailer] Localized refinement failed for face #%d: %s. Preserving original.", idx + 1, ref_err)
|
|
279
|
+
|
|
280
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
281
|
+
working_canvas.save(dest_path, "PNG")
|
|
282
|
+
logger.info("[FaceDetailer] All detected faces refined and merged -> %s", dest_path)
|
|
283
|
+
return dest_path
|