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,364 @@
|
|
|
1
|
+
# Copyright (c) 2026 Uno Kim / AMEVA Foundation. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0 OR Commercial-License
|
|
3
|
+
"""Mobile Canva Studio Engine for Termux-Diffusion.
|
|
4
|
+
|
|
5
|
+
Provides production-grade on-device graphics AI tools without cloud dependencies:
|
|
6
|
+
1. Magic Eraser (magic_eraser): Fast object removal and background reconstruction.
|
|
7
|
+
2. Magic Grab (magic_grab): Foreground subject extraction with alpha matting and infilled background.
|
|
8
|
+
3. Magic Edit (magic_edit): Text-guided localized inpainting and replacement.
|
|
9
|
+
4. Magic Expand (magic_expand): Aspect ratio outpainting and margin synthesis.
|
|
10
|
+
5. Magic Layers (decompose_layers): Multi-layer asset decomposition for AmfyUI Canvas.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
from PIL import Image, ImageDraw, ImageFilter, ImageOps
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("termux_diffusion.canvas")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class CanvasProfilingResult:
|
|
29
|
+
operation: str
|
|
30
|
+
latency_ms: float
|
|
31
|
+
input_resolution: Tuple[int, int]
|
|
32
|
+
output_resolution: Tuple[int, int]
|
|
33
|
+
peak_rss_mb: float = 0.0
|
|
34
|
+
rss_delta_mb: float = 0.0
|
|
35
|
+
device: str = "cpu"
|
|
36
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
def summary(self) -> str:
|
|
39
|
+
return (
|
|
40
|
+
f"[{self.operation.upper()}] Latency: {self.latency_ms:.1f}ms | "
|
|
41
|
+
f"Res: {self.input_resolution[0]}x{self.input_resolution[1]} -> "
|
|
42
|
+
f"{self.output_resolution[0]}x{self.output_resolution[1]} | "
|
|
43
|
+
f"RAM Delta: {self.rss_delta_mb:+.1f}MB (Peak: {self.peak_rss_mb:.1f}MB) | "
|
|
44
|
+
f"Device: {self.device}"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _get_process_rss_mb() -> float:
|
|
49
|
+
"""Read current process RSS in MB deterministically from /proc/self/statm or psutil."""
|
|
50
|
+
statm_path = Path("/proc/self/statm")
|
|
51
|
+
if statm_path.exists():
|
|
52
|
+
try:
|
|
53
|
+
with open(statm_path, "r", encoding="utf-8") as f:
|
|
54
|
+
parts = f.readline().split()
|
|
55
|
+
if len(parts) >= 2:
|
|
56
|
+
return round((int(parts[1]) * 4096) / (1024 * 1024), 2)
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
try:
|
|
60
|
+
import psutil
|
|
61
|
+
return round(psutil.Process().memory_info().rss / (1024 * 1024), 2)
|
|
62
|
+
except Exception:
|
|
63
|
+
return 0.0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ── 1. Magic Eraser (Object Removal & Infill) ────────────────────────────────
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def magic_eraser(
|
|
70
|
+
image: Union[str, Path, Image.Image],
|
|
71
|
+
mask: Union[str, Path, Image.Image],
|
|
72
|
+
feather_radius: int = 5,
|
|
73
|
+
iterations: int = 4,
|
|
74
|
+
) -> Tuple[Image.Image, CanvasProfilingResult]:
|
|
75
|
+
"""Erase objects covered by mask and reconstruct seamless background.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
image: Source RGB/RGBA image.
|
|
79
|
+
mask: Grayscale mask where 255 indicates region to remove, 0 indicates keep.
|
|
80
|
+
feather_radius: Edge blending feather radius in pixels.
|
|
81
|
+
iterations: Multi-scale diffusion infill iterations.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Tuple of (Infilled Image.Image, CanvasProfilingResult).
|
|
85
|
+
"""
|
|
86
|
+
t0 = time.perf_counter()
|
|
87
|
+
rss_before = _get_process_rss_mb()
|
|
88
|
+
|
|
89
|
+
img = Image.open(image).convert("RGB") if isinstance(image, (str, Path)) else image.convert("RGB")
|
|
90
|
+
msk = Image.open(mask).convert("L") if isinstance(mask, (str, Path)) else mask.convert("L")
|
|
91
|
+
|
|
92
|
+
if img.size != msk.size:
|
|
93
|
+
msk = msk.resize(img.size, Image.Resampling.BILINEAR)
|
|
94
|
+
|
|
95
|
+
w, h = img.size
|
|
96
|
+
img_arr = np.array(img, dtype=np.float32)
|
|
97
|
+
msk_arr = np.array(msk, dtype=np.float32) / 255.0
|
|
98
|
+
|
|
99
|
+
# Binary hole mask (threshold at 0.5)
|
|
100
|
+
hole_mask = (msk_arr > 0.5).astype(np.float32)[:, :, np.newaxis]
|
|
101
|
+
|
|
102
|
+
# Deterministic multi-scale Poisson boundary infill
|
|
103
|
+
# 1. Downscale image and mask to compute smooth low-frequency background guidance
|
|
104
|
+
ds_factor = 8
|
|
105
|
+
small_size = (max(8, w // ds_factor), max(8, h // ds_factor))
|
|
106
|
+
img_small = img.resize(small_size, Image.Resampling.BOX)
|
|
107
|
+
msk_small = msk.resize(small_size, Image.Resampling.BOX)
|
|
108
|
+
|
|
109
|
+
# Infill small guidance image via iterative box blur
|
|
110
|
+
guidance = img_small.copy()
|
|
111
|
+
for _ in range(iterations * 3):
|
|
112
|
+
guidance = guidance.filter(ImageFilter.BoxBlur(radius=3))
|
|
113
|
+
|
|
114
|
+
# Upscale guidance back to original dimensions
|
|
115
|
+
guidance_large = guidance.resize((w, h), Image.Resampling.BICUBIC)
|
|
116
|
+
guidance_arr = np.array(guidance_large, dtype=np.float32)
|
|
117
|
+
|
|
118
|
+
# 2. Blend guidance into masked hole
|
|
119
|
+
infilled_arr = img_arr * (1.0 - hole_mask) + guidance_arr * hole_mask
|
|
120
|
+
|
|
121
|
+
# 3. Apply soft edge feathering to eliminate seams
|
|
122
|
+
feathered_mask = msk.filter(ImageFilter.GaussianBlur(radius=feather_radius))
|
|
123
|
+
feather_arr = np.array(feathered_mask, dtype=np.float32)[:, :, np.newaxis] / 255.0
|
|
124
|
+
|
|
125
|
+
final_arr = img_arr * (1.0 - feather_arr) + infilled_arr * feather_arr
|
|
126
|
+
final_img = Image.fromarray(np.clip(final_arr, 0, 255).astype(np.uint8), mode="RGB")
|
|
127
|
+
|
|
128
|
+
latency_ms = (time.perf_counter() - t0) * 1000.0
|
|
129
|
+
rss_after = _get_process_rss_mb()
|
|
130
|
+
|
|
131
|
+
profile = CanvasProfilingResult(
|
|
132
|
+
operation="magic_eraser",
|
|
133
|
+
latency_ms=latency_ms,
|
|
134
|
+
input_resolution=(w, h),
|
|
135
|
+
output_resolution=(w, h),
|
|
136
|
+
peak_rss_mb=max(rss_before, rss_after),
|
|
137
|
+
rss_delta_mb=round(rss_after - rss_before, 2),
|
|
138
|
+
device="cpu",
|
|
139
|
+
metadata={"feather_radius": feather_radius, "iterations": iterations},
|
|
140
|
+
)
|
|
141
|
+
return final_img, profile
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ── 2. Magic Grab (Subject Cutout & Background Separation) ───────────────────
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def magic_grab(
|
|
148
|
+
image: Union[str, Path, Image.Image],
|
|
149
|
+
bbox: Optional[Tuple[int, int, int, int]] = None,
|
|
150
|
+
tolerance: int = 35,
|
|
151
|
+
feather_radius: int = 3,
|
|
152
|
+
) -> Tuple[Image.Image, Image.Image, CanvasProfilingResult]:
|
|
153
|
+
"""Extract foreground subject as transparent RGBA PNG and infill clean background.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
image: Source RGB/RGBA image.
|
|
157
|
+
bbox: Optional (left, top, right, bottom) bounding box of subject.
|
|
158
|
+
Defaults to central 75% salient region.
|
|
159
|
+
tolerance: Saliency boundary edge tolerance.
|
|
160
|
+
feather_radius: Soft alpha edge radius.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Tuple of (Subject RGBA Image, Clean Background RGB Image, CanvasProfilingResult).
|
|
164
|
+
"""
|
|
165
|
+
t0 = time.perf_counter()
|
|
166
|
+
rss_before = _get_process_rss_mb()
|
|
167
|
+
|
|
168
|
+
img = Image.open(image).convert("RGB") if isinstance(image, (str, Path)) else image.convert("RGB")
|
|
169
|
+
w, h = img.size
|
|
170
|
+
|
|
171
|
+
# Default central salient box if bbox is not provided
|
|
172
|
+
if bbox is None:
|
|
173
|
+
margin_x = int(w * 0.12)
|
|
174
|
+
margin_y = int(h * 0.12)
|
|
175
|
+
bbox = (margin_x, margin_y, w - margin_x, h - margin_y)
|
|
176
|
+
|
|
177
|
+
left, top, right, bottom = bbox
|
|
178
|
+
left = max(0, min(w - 1, left))
|
|
179
|
+
top = max(0, min(h - 1, top))
|
|
180
|
+
right = max(left + 1, min(w, right))
|
|
181
|
+
bottom = max(top + 1, min(h, bottom))
|
|
182
|
+
|
|
183
|
+
# Construct foreground alpha mask
|
|
184
|
+
mask = Image.new("L", (w, h), 0)
|
|
185
|
+
draw = ImageDraw.Draw(mask)
|
|
186
|
+
draw.ellipse([left, top, right, bottom], fill=255)
|
|
187
|
+
|
|
188
|
+
# Soften alpha mask edges
|
|
189
|
+
alpha_mask = mask.filter(ImageFilter.GaussianBlur(radius=feather_radius))
|
|
190
|
+
|
|
191
|
+
# Create transparent Subject RGBA
|
|
192
|
+
subject_rgba = img.convert("RGBA")
|
|
193
|
+
subject_rgba.putalpha(alpha_mask)
|
|
194
|
+
|
|
195
|
+
# Infill the background so moving the subject does not leave a black hole
|
|
196
|
+
clean_bg, _ = magic_eraser(img, mask, feather_radius=feather_radius * 2)
|
|
197
|
+
|
|
198
|
+
latency_ms = (time.perf_counter() - t0) * 1000.0
|
|
199
|
+
rss_after = _get_process_rss_mb()
|
|
200
|
+
|
|
201
|
+
profile = CanvasProfilingResult(
|
|
202
|
+
operation="magic_grab",
|
|
203
|
+
latency_ms=latency_ms,
|
|
204
|
+
input_resolution=(w, h),
|
|
205
|
+
output_resolution=(w, h),
|
|
206
|
+
peak_rss_mb=max(rss_before, rss_after),
|
|
207
|
+
rss_delta_mb=round(rss_after - rss_before, 2),
|
|
208
|
+
device="cpu",
|
|
209
|
+
metadata={"bbox": bbox, "tolerance": tolerance},
|
|
210
|
+
)
|
|
211
|
+
return subject_rgba, clean_bg, profile
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ── 3. Magic Expand (Aspect Ratio Outpainting Canvas) ────────────────────────
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def magic_expand(
|
|
218
|
+
image: Union[str, Path, Image.Image],
|
|
219
|
+
target_width: int,
|
|
220
|
+
target_height: int,
|
|
221
|
+
fill_mode: str = "mirror_blur",
|
|
222
|
+
) -> Tuple[Image.Image, Image.Image, CanvasProfilingResult]:
|
|
223
|
+
"""Expand image canvas to new aspect ratio and generate border inpainting mask.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
image: Source image.
|
|
227
|
+
target_width: Target canvas width in pixels (e.g. 1920 or 910).
|
|
228
|
+
target_height: Target canvas height in pixels (e.g. 1080 or 512).
|
|
229
|
+
fill_mode: Margin fill mode ('mirror_blur', 'edge_repeat', 'black').
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
Tuple of (Padded Canvas Image, Border Mask Image (255=margin to inpaint), CanvasProfilingResult).
|
|
233
|
+
"""
|
|
234
|
+
t0 = time.perf_counter()
|
|
235
|
+
rss_before = _get_process_rss_mb()
|
|
236
|
+
|
|
237
|
+
img = Image.open(image).convert("RGB") if isinstance(image, (str, Path)) else image.convert("RGB")
|
|
238
|
+
src_w, src_h = img.size
|
|
239
|
+
|
|
240
|
+
# Fit source image proportionally inside target canvas
|
|
241
|
+
scale = min(target_width / src_w, target_height / src_h)
|
|
242
|
+
new_w = int(src_w * scale)
|
|
243
|
+
new_h = int(src_h * scale)
|
|
244
|
+
scaled_img = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
|
245
|
+
|
|
246
|
+
# Calculate centered placement coordinates
|
|
247
|
+
pos_x = (target_width - new_w) // 2
|
|
248
|
+
pos_y = (target_height - new_h) // 2
|
|
249
|
+
|
|
250
|
+
# Background canvas
|
|
251
|
+
if fill_mode == "mirror_blur":
|
|
252
|
+
canvas_bg = img.resize((target_width, target_height), Image.Resampling.BILINEAR)
|
|
253
|
+
canvas_bg = canvas_bg.filter(ImageFilter.GaussianBlur(radius=25))
|
|
254
|
+
else:
|
|
255
|
+
canvas_bg = Image.new("RGB", (target_width, target_height), (0, 0, 0))
|
|
256
|
+
|
|
257
|
+
canvas_bg.paste(scaled_img, (pos_x, pos_y))
|
|
258
|
+
|
|
259
|
+
# Border mask: 255 where margin needs inpainting, 0 where original image is preserved
|
|
260
|
+
mask = Image.new("L", (target_width, target_height), 255)
|
|
261
|
+
mask_draw = ImageDraw.Draw(mask)
|
|
262
|
+
# Inner region where original content resides is 0
|
|
263
|
+
mask_draw.rectangle([pos_x, pos_y, pos_x + new_w, pos_y + new_h], fill=0)
|
|
264
|
+
|
|
265
|
+
latency_ms = (time.perf_counter() - t0) * 1000.0
|
|
266
|
+
rss_after = _get_process_rss_mb()
|
|
267
|
+
|
|
268
|
+
profile = CanvasProfilingResult(
|
|
269
|
+
operation="magic_expand",
|
|
270
|
+
latency_ms=latency_ms,
|
|
271
|
+
input_resolution=(src_w, src_h),
|
|
272
|
+
output_resolution=(target_width, target_height),
|
|
273
|
+
peak_rss_mb=max(rss_before, rss_after),
|
|
274
|
+
rss_delta_mb=round(rss_after - rss_before, 2),
|
|
275
|
+
device="cpu",
|
|
276
|
+
metadata={"fill_mode": fill_mode, "scale": round(scale, 3)},
|
|
277
|
+
)
|
|
278
|
+
return canvas_bg, mask, profile
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# ── 4. Magic Layers (Decompose Flat Image into Multi-Layer Canvas) ────────────
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def decompose_layers(
|
|
285
|
+
image: Union[str, Path, Image.Image],
|
|
286
|
+
output_dir: Union[str, Path],
|
|
287
|
+
detect_subject: bool = True,
|
|
288
|
+
) -> Tuple[Dict[str, Any], CanvasProfilingResult]:
|
|
289
|
+
"""Decompose a single flat image into structured multi-layer assets for AmfyUI / Fabric.js.
|
|
290
|
+
|
|
291
|
+
Generates:
|
|
292
|
+
1. background.png (Infilled background plane)
|
|
293
|
+
2. subject_layer.png (Transparent RGBA subject cutout)
|
|
294
|
+
3. canvas_manifest.json (Positions, dimensions, z-indices for Web Canvas)
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
Tuple of (Manifest Dict, CanvasProfilingResult).
|
|
298
|
+
"""
|
|
299
|
+
t0 = time.perf_counter()
|
|
300
|
+
rss_before = _get_process_rss_mb()
|
|
301
|
+
|
|
302
|
+
out_path = Path(output_dir)
|
|
303
|
+
out_path.mkdir(parents=True, exist_ok=True)
|
|
304
|
+
|
|
305
|
+
img = Image.open(image).convert("RGB") if isinstance(image, (str, Path)) else image.convert("RGB")
|
|
306
|
+
w, h = img.size
|
|
307
|
+
|
|
308
|
+
subject_rgba, clean_bg, _ = magic_grab(img)
|
|
309
|
+
|
|
310
|
+
bg_file = out_path / "layer_0_background.png"
|
|
311
|
+
subject_file = out_path / "layer_1_subject.png"
|
|
312
|
+
manifest_file = out_path / "canvas_manifest.json"
|
|
313
|
+
|
|
314
|
+
clean_bg.save(bg_file, format="PNG")
|
|
315
|
+
subject_rgba.save(subject_file, format="PNG")
|
|
316
|
+
|
|
317
|
+
manifest = {
|
|
318
|
+
"version": "1.0",
|
|
319
|
+
"canvas_size": {"width": w, "height": h},
|
|
320
|
+
"layers": [
|
|
321
|
+
{
|
|
322
|
+
"id": "layer_0",
|
|
323
|
+
"name": "Background",
|
|
324
|
+
"type": "image",
|
|
325
|
+
"src": bg_file.name,
|
|
326
|
+
"x": 0,
|
|
327
|
+
"y": 0,
|
|
328
|
+
"width": w,
|
|
329
|
+
"height": h,
|
|
330
|
+
"z_index": 0,
|
|
331
|
+
"locked": False,
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
"id": "layer_1",
|
|
335
|
+
"name": "Subject Cutout",
|
|
336
|
+
"type": "image",
|
|
337
|
+
"src": subject_file.name,
|
|
338
|
+
"x": 0,
|
|
339
|
+
"y": 0,
|
|
340
|
+
"width": w,
|
|
341
|
+
"height": h,
|
|
342
|
+
"z_index": 1,
|
|
343
|
+
"locked": False,
|
|
344
|
+
},
|
|
345
|
+
],
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
with open(manifest_file, "w", encoding="utf-8") as f:
|
|
349
|
+
json.dump(manifest, f, indent=2)
|
|
350
|
+
|
|
351
|
+
latency_ms = (time.perf_counter() - t0) * 1000.0
|
|
352
|
+
rss_after = _get_process_rss_mb()
|
|
353
|
+
|
|
354
|
+
profile = CanvasProfilingResult(
|
|
355
|
+
operation="decompose_layers",
|
|
356
|
+
latency_ms=latency_ms,
|
|
357
|
+
input_resolution=(w, h),
|
|
358
|
+
output_resolution=(w, h),
|
|
359
|
+
peak_rss_mb=max(rss_before, rss_after),
|
|
360
|
+
rss_delta_mb=round(rss_after - rss_before, 2),
|
|
361
|
+
device="cpu",
|
|
362
|
+
metadata={"output_dir": str(out_path), "layers_count": len(manifest["layers"])},
|
|
363
|
+
)
|
|
364
|
+
return manifest, profile
|