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.
Files changed (39) hide show
  1. amv_diffusion-2.1.0.dist-info/METADATA +180 -0
  2. amv_diffusion-2.1.0.dist-info/RECORD +39 -0
  3. amv_diffusion-2.1.0.dist-info/WHEEL +5 -0
  4. amv_diffusion-2.1.0.dist-info/entry_points.txt +7 -0
  5. amv_diffusion-2.1.0.dist-info/licenses/LICENSE +21 -0
  6. amv_diffusion-2.1.0.dist-info/top_level.txt +1 -0
  7. termux_diffusion/__init__.py +185 -0
  8. termux_diffusion/__main__.py +7 -0
  9. termux_diffusion/_version.py +3 -0
  10. termux_diffusion/adapter.py +41 -0
  11. termux_diffusion/canvas.py +364 -0
  12. termux_diffusion/cli.py +492 -0
  13. termux_diffusion/cluster.py +340 -0
  14. termux_diffusion/control/__init__.py +5 -0
  15. termux_diffusion/control/component.py +309 -0
  16. termux_diffusion/control/status.py +15 -0
  17. termux_diffusion/core.py +2936 -0
  18. termux_diffusion/data/bootstrap-manifest.json +15 -0
  19. termux_diffusion/data/prompt_cache/108a7149c4e70550.json +10 -0
  20. termux_diffusion/data/release-public-keys.json +10 -0
  21. termux_diffusion/data/validated-vulkan-profiles.json +298 -0
  22. termux_diffusion/detailer.py +283 -0
  23. termux_diffusion/doctor.py +128 -0
  24. termux_diffusion/downloader.py +111 -0
  25. termux_diffusion/engine.py +226 -0
  26. termux_diffusion/exceptions.py +158 -0
  27. termux_diffusion/hardware.py +985 -0
  28. termux_diffusion/hub.py +686 -0
  29. termux_diffusion/installer.py +499 -0
  30. termux_diffusion/locking.py +134 -0
  31. termux_diffusion/manifest.py +128 -0
  32. termux_diffusion/npu.py +267 -0
  33. termux_diffusion/platform.py +533 -0
  34. termux_diffusion/purger.py +145 -0
  35. termux_diffusion/py.typed +1 -0
  36. termux_diffusion/safety.py +150 -0
  37. termux_diffusion/selftest.py +178 -0
  38. termux_diffusion/web.py +3370 -0
  39. 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,10 @@
1
+ {
2
+ "schema_version": 1,
3
+ "keys": {
4
+ "release-key-2026-01": {
5
+ "algorithm": "ed25519",
6
+ "public_key_hex": "ea58ee6d830ca51164a3968c38e4abbad7fe39ebb761164821cba00524c15721",
7
+ "status": "active"
8
+ }
9
+ }
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