zimage-mnn 0.1.1__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.
- zimage_mnn-0.1.1/PKG-INFO +99 -0
- zimage_mnn-0.1.1/README.md +78 -0
- zimage_mnn-0.1.1/pyproject.toml +39 -0
- zimage_mnn-0.1.1/setup.cfg +4 -0
- zimage_mnn-0.1.1/zimage_mnn/__init__.py +5 -0
- zimage_mnn-0.1.1/zimage_mnn/api.py +90 -0
- zimage_mnn-0.1.1/zimage_mnn/bin/libMNN.dylib +0 -0
- zimage_mnn-0.1.1/zimage_mnn/bin/libMNN_Express.dylib +0 -0
- zimage_mnn-0.1.1/zimage_mnn/bin/zimage_mnn +0 -0
- zimage_mnn-0.1.1/zimage_mnn/cli.py +101 -0
- zimage_mnn-0.1.1/zimage_mnn/pipeline.py +262 -0
- zimage_mnn-0.1.1/zimage_mnn.egg-info/PKG-INFO +99 -0
- zimage_mnn-0.1.1/zimage_mnn.egg-info/SOURCES.txt +15 -0
- zimage_mnn-0.1.1/zimage_mnn.egg-info/dependency_links.txt +1 -0
- zimage_mnn-0.1.1/zimage_mnn.egg-info/entry_points.txt +2 -0
- zimage_mnn-0.1.1/zimage_mnn.egg-info/requires.txt +6 -0
- zimage_mnn-0.1.1/zimage_mnn.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zimage-mnn
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Z-Image-Turbo text-to-image generation on MNN (Apple Silicon Metal / CPU)
|
|
5
|
+
Author: ws
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/alibaba/MNN
|
|
8
|
+
Keywords: mnn,diffusion,text-to-image,z-image,apple-silicon
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
|
+
Classifier: Operating System :: MacOS
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Requires-Dist: numpy
|
|
16
|
+
Requires-Dist: pillow
|
|
17
|
+
Requires-Dist: jinja2
|
|
18
|
+
Requires-Dist: mnn>=3.6.0
|
|
19
|
+
Requires-Dist: transformers>=4.40
|
|
20
|
+
Requires-Dist: huggingface_hub
|
|
21
|
+
|
|
22
|
+
# zimage-mnn
|
|
23
|
+
|
|
24
|
+
Run [Z-Image-Turbo](https://huggingface.co/Tongyi-MAI/Z-Image-Turbo) text-to-image locally with
|
|
25
|
+
[MNN](https://github.com/alibaba/MNN) — optimized for Apple Silicon (Metal) with a CPU fallback.
|
|
26
|
+
|
|
27
|
+
The 12.3B-parameter DiT is quantized to int8 weights and runs the full pipeline
|
|
28
|
+
(text encoder → 8-step denoiser → VAE) entirely on-device. No cloud calls.
|
|
29
|
+
|
|
30
|
+
## Install & run (one line)
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
uvx zimage-mnn "a cute cat sitting on a windowsill"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Models (~11 GB) download automatically on first run and are cached in `~/.cache/zimage_mnn`.
|
|
37
|
+
|
|
38
|
+
## CLI usage
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# basic
|
|
42
|
+
uvx zimage-mnn "a red sports car on a coastal highway"
|
|
43
|
+
|
|
44
|
+
# options
|
|
45
|
+
uvx zimage-mnn "a panda eating bamboo" \
|
|
46
|
+
--size 1024 \ # resolution (multiple of 16; model tuned for 1024)
|
|
47
|
+
--steps 8 \ # denoising steps (2-12)
|
|
48
|
+
--seed 42 \ # random seed
|
|
49
|
+
--num-images 2 \ # generate several (seed increments)
|
|
50
|
+
--output out.png \ # output file / prefix
|
|
51
|
+
--backend metal # auto | metal | cpu
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## HTTP API
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
uvx zimage-mnn --api --port 8000
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
curl -X POST http://127.0.0.1:8000/generate \
|
|
62
|
+
-H "Content-Type: application/json" \
|
|
63
|
+
-d '{"prompt":"a cute cat","size":1024,"steps":8,"seed":42}' \
|
|
64
|
+
-o cat.png
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`GET /health` returns `{"status":"ok"}`.
|
|
68
|
+
|
|
69
|
+
## Python API
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from zimage_mnn import ZImageMNNPipeline
|
|
73
|
+
|
|
74
|
+
pipe = ZImageMNNPipeline.from_pretrained() # downloads models
|
|
75
|
+
image = pipe("a cute cat", size=1024, steps=8, seed=42)
|
|
76
|
+
image.save("cat.png")
|
|
77
|
+
|
|
78
|
+
images = pipe.generate("a dog", num_images=3, seed=7)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Performance (Apple M5 Pro, 1024×1024, 8 steps)
|
|
82
|
+
|
|
83
|
+
| Backend | per step | end-to-end |
|
|
84
|
+
|---------|----------|------------|
|
|
85
|
+
| Metal (fp32, int8 weights) | ~13 s | ~2 min |
|
|
86
|
+
| CPU (int8, 12 threads) | ~27 s | ~4 min |
|
|
87
|
+
|
|
88
|
+
Quality matches the original PyTorch pipeline (single-step cosine ≈ 0.9996).
|
|
89
|
+
|
|
90
|
+
## Requirements
|
|
91
|
+
|
|
92
|
+
- Python ≥ 3.9
|
|
93
|
+
- macOS with Apple Silicon recommended (Metal). CPU works anywhere but is slower.
|
|
94
|
+
- ~16 GB RAM recommended; ~11 GB disk for the models.
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
Apache-2.0. Model weights derive from
|
|
99
|
+
[Tongyi-MAI/Z-Image-Turbo](https://huggingface.co/Tongyi-MAI/Z-Image-Turbo) — see its license.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# zimage-mnn
|
|
2
|
+
|
|
3
|
+
Run [Z-Image-Turbo](https://huggingface.co/Tongyi-MAI/Z-Image-Turbo) text-to-image locally with
|
|
4
|
+
[MNN](https://github.com/alibaba/MNN) — optimized for Apple Silicon (Metal) with a CPU fallback.
|
|
5
|
+
|
|
6
|
+
The 12.3B-parameter DiT is quantized to int8 weights and runs the full pipeline
|
|
7
|
+
(text encoder → 8-step denoiser → VAE) entirely on-device. No cloud calls.
|
|
8
|
+
|
|
9
|
+
## Install & run (one line)
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uvx zimage-mnn "a cute cat sitting on a windowsill"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Models (~11 GB) download automatically on first run and are cached in `~/.cache/zimage_mnn`.
|
|
16
|
+
|
|
17
|
+
## CLI usage
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# basic
|
|
21
|
+
uvx zimage-mnn "a red sports car on a coastal highway"
|
|
22
|
+
|
|
23
|
+
# options
|
|
24
|
+
uvx zimage-mnn "a panda eating bamboo" \
|
|
25
|
+
--size 1024 \ # resolution (multiple of 16; model tuned for 1024)
|
|
26
|
+
--steps 8 \ # denoising steps (2-12)
|
|
27
|
+
--seed 42 \ # random seed
|
|
28
|
+
--num-images 2 \ # generate several (seed increments)
|
|
29
|
+
--output out.png \ # output file / prefix
|
|
30
|
+
--backend metal # auto | metal | cpu
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## HTTP API
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uvx zimage-mnn --api --port 8000
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
curl -X POST http://127.0.0.1:8000/generate \
|
|
41
|
+
-H "Content-Type: application/json" \
|
|
42
|
+
-d '{"prompt":"a cute cat","size":1024,"steps":8,"seed":42}' \
|
|
43
|
+
-o cat.png
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`GET /health` returns `{"status":"ok"}`.
|
|
47
|
+
|
|
48
|
+
## Python API
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from zimage_mnn import ZImageMNNPipeline
|
|
52
|
+
|
|
53
|
+
pipe = ZImageMNNPipeline.from_pretrained() # downloads models
|
|
54
|
+
image = pipe("a cute cat", size=1024, steps=8, seed=42)
|
|
55
|
+
image.save("cat.png")
|
|
56
|
+
|
|
57
|
+
images = pipe.generate("a dog", num_images=3, seed=7)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Performance (Apple M5 Pro, 1024×1024, 8 steps)
|
|
61
|
+
|
|
62
|
+
| Backend | per step | end-to-end |
|
|
63
|
+
|---------|----------|------------|
|
|
64
|
+
| Metal (fp32, int8 weights) | ~13 s | ~2 min |
|
|
65
|
+
| CPU (int8, 12 threads) | ~27 s | ~4 min |
|
|
66
|
+
|
|
67
|
+
Quality matches the original PyTorch pipeline (single-step cosine ≈ 0.9996).
|
|
68
|
+
|
|
69
|
+
## Requirements
|
|
70
|
+
|
|
71
|
+
- Python ≥ 3.9
|
|
72
|
+
- macOS with Apple Silicon recommended (Metal). CPU works anywhere but is slower.
|
|
73
|
+
- ~16 GB RAM recommended; ~11 GB disk for the models.
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
Apache-2.0. Model weights derive from
|
|
78
|
+
[Tongyi-MAI/Z-Image-Turbo](https://huggingface.co/Tongyi-MAI/Z-Image-Turbo) — see its license.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "zimage-mnn"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Z-Image-Turbo text-to-image generation on MNN (Apple Silicon Metal / CPU)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [{ name = "ws" }]
|
|
13
|
+
keywords = ["mnn", "diffusion", "text-to-image", "z-image", "apple-silicon"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: Apache Software License",
|
|
17
|
+
"Operating System :: MacOS",
|
|
18
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"numpy",
|
|
22
|
+
"pillow",
|
|
23
|
+
"jinja2",
|
|
24
|
+
"mnn>=3.6.0",
|
|
25
|
+
"transformers>=4.40",
|
|
26
|
+
"huggingface_hub",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
zimage-mnn = "zimage_mnn.cli:main"
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/alibaba/MNN"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools]
|
|
36
|
+
packages = ["zimage_mnn"]
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.package-data]
|
|
39
|
+
zimage_mnn = ["bin/*"]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Minimal HTTP API server for zimage-mnn (stdlib only, no extra deps).
|
|
2
|
+
|
|
3
|
+
Endpoints:
|
|
4
|
+
GET /health -> {"status": "ok"}
|
|
5
|
+
POST /generate -> returns PNG bytes
|
|
6
|
+
body (JSON): {"prompt": str, "size": int, "steps": int, "seed": int, "num_images": int}
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import io
|
|
11
|
+
import json
|
|
12
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def serve(args):
|
|
16
|
+
from .pipeline import DEFAULT_REPO, ZImageMNNPipeline
|
|
17
|
+
|
|
18
|
+
repo = args.repo or DEFAULT_REPO
|
|
19
|
+
print(f"Loading pipeline (models from {repo})...", flush=True)
|
|
20
|
+
pipe = ZImageMNNPipeline.from_pretrained(repo, model_dir=args.model_dir, backend=args.backend)
|
|
21
|
+
print("Pipeline ready.", flush=True)
|
|
22
|
+
|
|
23
|
+
class Handler(BaseHTTPRequestHandler):
|
|
24
|
+
def log_message(self, fmt, *a): # quieter logs
|
|
25
|
+
print("[api]", fmt % a, flush=True)
|
|
26
|
+
|
|
27
|
+
def _send_json(self, obj, code=200):
|
|
28
|
+
body = json.dumps(obj).encode()
|
|
29
|
+
self.send_response(code)
|
|
30
|
+
self.send_header("Content-Type", "application/json")
|
|
31
|
+
self.send_header("Content-Length", str(len(body)))
|
|
32
|
+
self.end_headers()
|
|
33
|
+
self.wfile.write(body)
|
|
34
|
+
|
|
35
|
+
def do_GET(self):
|
|
36
|
+
if self.path in ("/health", "/"):
|
|
37
|
+
self._send_json({"status": "ok"})
|
|
38
|
+
else:
|
|
39
|
+
self._send_json({"error": "not found"}, 404)
|
|
40
|
+
|
|
41
|
+
def do_POST(self):
|
|
42
|
+
if self.path != "/generate":
|
|
43
|
+
self._send_json({"error": "not found"}, 404)
|
|
44
|
+
return
|
|
45
|
+
try:
|
|
46
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
47
|
+
req = json.loads(self.rfile.read(length) or b"{}")
|
|
48
|
+
prompt = req.get("prompt")
|
|
49
|
+
if not prompt:
|
|
50
|
+
self._send_json({"error": "prompt is required"}, 400)
|
|
51
|
+
return
|
|
52
|
+
size = int(req.get("size", args.size))
|
|
53
|
+
steps = int(req.get("steps", args.steps))
|
|
54
|
+
seed = int(req.get("seed", args.seed))
|
|
55
|
+
num_images = int(req.get("num_images", 1))
|
|
56
|
+
|
|
57
|
+
images = pipe.generate(prompt, num_images=num_images, size=size,
|
|
58
|
+
steps=steps, seed=seed, verbose=False)
|
|
59
|
+
if num_images == 1:
|
|
60
|
+
buf = io.BytesIO()
|
|
61
|
+
images[0].save(buf, format="PNG")
|
|
62
|
+
data = buf.getvalue()
|
|
63
|
+
self.send_response(200)
|
|
64
|
+
self.send_header("Content-Type", "image/png")
|
|
65
|
+
self.send_header("Content-Length", str(len(data)))
|
|
66
|
+
self.end_headers()
|
|
67
|
+
self.wfile.write(data)
|
|
68
|
+
else:
|
|
69
|
+
# multiple images -> return JSON with base64
|
|
70
|
+
import base64
|
|
71
|
+
|
|
72
|
+
out = []
|
|
73
|
+
for img in images:
|
|
74
|
+
buf = io.BytesIO()
|
|
75
|
+
img.save(buf, format="PNG")
|
|
76
|
+
out.append(base64.b64encode(buf.getvalue()).decode())
|
|
77
|
+
self._send_json({"images": out})
|
|
78
|
+
except Exception as e: # noqa: BLE001
|
|
79
|
+
self._send_json({"error": str(e)}, 500)
|
|
80
|
+
|
|
81
|
+
server = ThreadingHTTPServer((args.host, args.port), Handler)
|
|
82
|
+
print(f"Serving on http://{args.host}:{args.port}", flush=True)
|
|
83
|
+
print('Try: curl -X POST http://%s:%d/generate -H "Content-Type: application/json" '
|
|
84
|
+
'-d \'{"prompt":"a cute cat"}\' -o out.png' % (args.host, args.port), flush=True)
|
|
85
|
+
try:
|
|
86
|
+
server.serve_forever()
|
|
87
|
+
except KeyboardInterrupt:
|
|
88
|
+
pass
|
|
89
|
+
finally:
|
|
90
|
+
server.server_close()
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Command-line interface for zimage-mnn.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
uvx zimage-mnn "a cute cat" --size 1024 --steps 8 --num-images 2
|
|
5
|
+
uvx zimage-mnn --api --port 8000
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
15
|
+
p = argparse.ArgumentParser(
|
|
16
|
+
prog="zimage-mnn",
|
|
17
|
+
description="Z-Image-Turbo text-to-image on MNN (Apple Silicon / CPU).")
|
|
18
|
+
p.add_argument("prompt", nargs="?", help="Text prompt for image generation.")
|
|
19
|
+
p.add_argument("--repo", default=None, help="HF repo id to download models from.")
|
|
20
|
+
p.add_argument("--model-dir", default=None, help="Use a local model directory instead of downloading.")
|
|
21
|
+
p.add_argument("--size", type=int, default=1024, help="Image resolution (width=height). Must be a multiple of 16.")
|
|
22
|
+
p.add_argument("--width", type=int, default=None, help="Image width (overrides --size).")
|
|
23
|
+
p.add_argument("--height", type=int, default=None, help="Image height (overrides --size).")
|
|
24
|
+
p.add_argument("--steps", type=int, default=8, help="Number of denoising steps (2-12).")
|
|
25
|
+
p.add_argument("--seed", type=int, default=42, help="Random seed.")
|
|
26
|
+
p.add_argument("--num-images", "-n", type=int, default=1, help="Number of images to generate.")
|
|
27
|
+
p.add_argument("--output", "-o", default="zimage.png", help="Output file (or prefix for multiple images).")
|
|
28
|
+
p.add_argument("--backend", default="auto", choices=["auto", "metal", "cpu"], help="Compute backend.")
|
|
29
|
+
p.add_argument("--api", action="store_true", help="Run an HTTP API server instead of generating.")
|
|
30
|
+
p.add_argument("--host", default="127.0.0.1", help="API host.")
|
|
31
|
+
p.add_argument("--port", type=int, default=8000, help="API port.")
|
|
32
|
+
p.add_argument("--quiet", "-q", action="store_true", help="Suppress progress output.")
|
|
33
|
+
return p
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _save_images(images, output: str):
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
saved = []
|
|
40
|
+
if len(images) == 1:
|
|
41
|
+
Path(output).parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
images[0].save(output)
|
|
43
|
+
saved.append(output)
|
|
44
|
+
else:
|
|
45
|
+
stem = Path(output)
|
|
46
|
+
stem.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
base = stem.with_suffix("")
|
|
48
|
+
ext = stem.suffix or ".png"
|
|
49
|
+
for i, img in enumerate(images):
|
|
50
|
+
p = f"{base}_{i}{ext}"
|
|
51
|
+
img.save(p)
|
|
52
|
+
saved.append(p)
|
|
53
|
+
return saved
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def run_api(args):
|
|
57
|
+
from .api import serve
|
|
58
|
+
|
|
59
|
+
serve(args)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main(argv=None):
|
|
63
|
+
args = build_parser().parse_args(argv)
|
|
64
|
+
|
|
65
|
+
if args.api:
|
|
66
|
+
run_api(args)
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
if not args.prompt:
|
|
70
|
+
print("Error: a prompt is required (or use --api).", file=sys.stderr)
|
|
71
|
+
return 1
|
|
72
|
+
|
|
73
|
+
from .pipeline import DEFAULT_REPO, ZImageMNNPipeline
|
|
74
|
+
|
|
75
|
+
repo = args.repo or DEFAULT_REPO
|
|
76
|
+
t0 = time.time()
|
|
77
|
+
pipe = ZImageMNNPipeline.from_pretrained(
|
|
78
|
+
repo, model_dir=args.model_dir, backend=args.backend)
|
|
79
|
+
if not args.quiet:
|
|
80
|
+
print(f"Pipeline ready in {time.time() - t0:.1f}s", flush=True)
|
|
81
|
+
|
|
82
|
+
width = args.width or args.size
|
|
83
|
+
height = args.height or args.size
|
|
84
|
+
if width % 16 or height % 16:
|
|
85
|
+
print("Error: width/height must be multiples of 16.", file=sys.stderr)
|
|
86
|
+
return 1
|
|
87
|
+
|
|
88
|
+
images = []
|
|
89
|
+
for i in range(args.num_images):
|
|
90
|
+
img = pipe(args.prompt, size=width, steps=args.steps, seed=args.seed + i,
|
|
91
|
+
verbose=not args.quiet)
|
|
92
|
+
images.append(img)
|
|
93
|
+
|
|
94
|
+
saved = _save_images(images, args.output)
|
|
95
|
+
for s in saved:
|
|
96
|
+
print(f"Saved: {s}")
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
if __name__ == "__main__":
|
|
101
|
+
sys.exit(main())
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Z-Image-Turbo MNN inference pipeline.
|
|
2
|
+
|
|
3
|
+
Runs the full text-to-image pipeline on MNN:
|
|
4
|
+
text encoder (Qwen3) -> DiT denoiser (FlowMatch Euler) -> VAE decoder.
|
|
5
|
+
|
|
6
|
+
Models are downloaded from HuggingFace on first use and cached locally.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import struct
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
DEFAULT_REPO = "yunfengwang/z-image-turbo-mnn"
|
|
18
|
+
CACHE_DIR = Path(os.environ.get("ZIMAGE_MNN_CACHE", Path.home() / ".cache" / "zimage_mnn"))
|
|
19
|
+
|
|
20
|
+
# VAE post-processing constants (from the original model config)
|
|
21
|
+
VAE_SCALING_FACTOR = 0.3611
|
|
22
|
+
VAE_SHIFT_FACTOR = 0.1159
|
|
23
|
+
SCHEDULER_SHIFT = 3.0
|
|
24
|
+
CAP_LEN = 256 # fixed text-embedding length the unet was exported with
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _download_models(repo_id: str, model_dir: Path) -> Path:
|
|
28
|
+
"""Download model files from HuggingFace if not already cached."""
|
|
29
|
+
needed = [
|
|
30
|
+
"unet.mnn", "unet.mnn.weight",
|
|
31
|
+
"text_encoder.mnn", "text_encoder.mnn.weight",
|
|
32
|
+
"vae_decoder.mnn",
|
|
33
|
+
"tokenizer/tokenizer.json", "tokenizer/tokenizer_config.json",
|
|
34
|
+
]
|
|
35
|
+
missing = [f for f in needed if not (model_dir / f).exists()]
|
|
36
|
+
if missing:
|
|
37
|
+
from huggingface_hub import snapshot_download
|
|
38
|
+
|
|
39
|
+
print(f"Downloading models from {repo_id} ...")
|
|
40
|
+
snapshot_download(repo_id, local_dir=str(model_dir))
|
|
41
|
+
return model_dir
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ZImageMNNPipeline:
|
|
45
|
+
"""MNN pipeline for Z-Image-Turbo text-to-image."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, model_dir: str | Path, backend: str = "auto"):
|
|
48
|
+
import MNN.expr as F # noqa: F401
|
|
49
|
+
import MNN.nn as nn # noqa: F401
|
|
50
|
+
|
|
51
|
+
self.model_dir = Path(model_dir)
|
|
52
|
+
self.backend = backend
|
|
53
|
+
self._tokenizer = None
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_pretrained(cls, repo_id: str = DEFAULT_REPO, model_dir: str | Path | None = None,
|
|
57
|
+
backend: str = "auto") -> "ZImageMNNPipeline":
|
|
58
|
+
model_dir = Path(model_dir) if model_dir else CACHE_DIR / repo_id.replace("/", "--")
|
|
59
|
+
_download_models(repo_id, model_dir)
|
|
60
|
+
return cls(model_dir, backend=backend)
|
|
61
|
+
|
|
62
|
+
# -- helpers ---------------------------------------------------------
|
|
63
|
+
def _load_tokenizer(self):
|
|
64
|
+
if self._tokenizer is None:
|
|
65
|
+
from transformers import AutoTokenizer
|
|
66
|
+
|
|
67
|
+
self._tokenizer = AutoTokenizer.from_pretrained(str(self.model_dir / "tokenizer"))
|
|
68
|
+
return self._tokenizer
|
|
69
|
+
|
|
70
|
+
def _metal_available(self) -> bool:
|
|
71
|
+
if self.backend == "cpu":
|
|
72
|
+
return False
|
|
73
|
+
if self.backend == "metal":
|
|
74
|
+
return True
|
|
75
|
+
# auto: try Metal, fall back to CPU
|
|
76
|
+
import platform
|
|
77
|
+
|
|
78
|
+
return platform.system() == "Darwin"
|
|
79
|
+
|
|
80
|
+
def _encode_prompt(self, prompt: str) -> np.ndarray:
|
|
81
|
+
import MNN.expr as F
|
|
82
|
+
import MNN.nn as nn
|
|
83
|
+
|
|
84
|
+
tokenizer = self._load_tokenizer()
|
|
85
|
+
max_length = 512
|
|
86
|
+
messages = [{"role": "user", "content": prompt}]
|
|
87
|
+
formatted = tokenizer.apply_chat_template(
|
|
88
|
+
messages, tokenize=False, add_generation_prompt=True, enable_thinking=True)
|
|
89
|
+
inputs = tokenizer([formatted], padding="max_length", max_length=max_length,
|
|
90
|
+
truncation=True, return_tensors="np")
|
|
91
|
+
input_ids = inputs.input_ids.astype(np.int64)
|
|
92
|
+
mask1d = inputs.attention_mask[0].astype(bool)
|
|
93
|
+
actual_len = int(mask1d.sum())
|
|
94
|
+
|
|
95
|
+
# 4D causal+padding mask: 0 = attend, -inf = masked
|
|
96
|
+
min_dtype = np.finfo(np.float32).min
|
|
97
|
+
causal = np.tril(np.ones((max_length, max_length), dtype=bool))
|
|
98
|
+
mask4d = np.where(causal & mask1d[np.newaxis, :], 0.0, min_dtype).astype(np.float32)
|
|
99
|
+
mask4d = mask4d[np.newaxis, np.newaxis, :, :]
|
|
100
|
+
|
|
101
|
+
rtmgr = nn.create_runtime_manager(
|
|
102
|
+
({"backend": "CPU", "precision": "normal", "numThread": os.cpu_count() or 4},))
|
|
103
|
+
te = nn.load_module_from_file(
|
|
104
|
+
str(self.model_dir / "text_encoder.mnn"),
|
|
105
|
+
["input_ids", "attention_mask"], ["last_hidden_state"], runtime_manager=rtmgr)
|
|
106
|
+
ids_var = F.const(input_ids, list(input_ids.shape), F.NCHW, F.int64)
|
|
107
|
+
mask_var = F.const(mask4d, list(mask4d.shape), F.NCHW)
|
|
108
|
+
out = te.forward([ids_var, mask_var])
|
|
109
|
+
hidden = np.array(out[0].read())
|
|
110
|
+
return hidden[0, :actual_len, :].astype(np.float32)
|
|
111
|
+
|
|
112
|
+
def _pad_cap(self, cap_feats: np.ndarray) -> np.ndarray:
|
|
113
|
+
n = min(cap_feats.shape[0], CAP_LEN)
|
|
114
|
+
padded = np.zeros((CAP_LEN, cap_feats.shape[1]), dtype=np.float32)
|
|
115
|
+
padded[:n] = cap_feats[:n]
|
|
116
|
+
padded[n:] = cap_feats[n - 1]
|
|
117
|
+
return padded[np.newaxis, :, :]
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def _make_sigmas(num_steps: int) -> np.ndarray:
|
|
121
|
+
sigmas = np.linspace(1.0, 1.0 / num_steps, num_steps)
|
|
122
|
+
sigmas = SCHEDULER_SHIFT * sigmas / (1 + (SCHEDULER_SHIFT - 1) * sigmas)
|
|
123
|
+
return np.append(sigmas, 0.0)
|
|
124
|
+
|
|
125
|
+
def _denoise(self, cap_input: np.ndarray, num_steps: int, size: int, seed: int,
|
|
126
|
+
verbose: bool = True) -> np.ndarray:
|
|
127
|
+
# Prefer the bundled C++ binary (fp16 Metal, ~2x faster than the Python API).
|
|
128
|
+
binary = self._find_binary()
|
|
129
|
+
if binary is not None and size == 1024:
|
|
130
|
+
result = self._denoise_binary(binary, cap_input, num_steps, seed, verbose)
|
|
131
|
+
if result is not None:
|
|
132
|
+
return result
|
|
133
|
+
return self._denoise_python(cap_input, num_steps, size, seed, verbose)
|
|
134
|
+
|
|
135
|
+
def _find_binary(self):
|
|
136
|
+
"""Locate the bundled zimage_mnn binary (macOS arm64 only)."""
|
|
137
|
+
import platform
|
|
138
|
+
|
|
139
|
+
if platform.system() != "Darwin" or platform.machine() != "arm64":
|
|
140
|
+
return None
|
|
141
|
+
candidate = Path(__file__).parent / "bin" / "zimage_mnn"
|
|
142
|
+
if candidate.exists() and os.access(candidate, os.X_OK):
|
|
143
|
+
return candidate
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
def _denoise_binary(self, binary: Path, cap_input: np.ndarray, num_steps: int, seed: int,
|
|
147
|
+
verbose: bool):
|
|
148
|
+
import subprocess
|
|
149
|
+
import tempfile
|
|
150
|
+
|
|
151
|
+
latent = 1024 // 8
|
|
152
|
+
# The binary reads embeddings (ZIMAGE_EMB) and initial noise (ZIMAGE_NOISE) from
|
|
153
|
+
# temp files, and writes latents to <output>.latents.bin.
|
|
154
|
+
with tempfile.TemporaryDirectory() as td:
|
|
155
|
+
emb_path = os.path.join(td, "emb.bin")
|
|
156
|
+
noise_path = os.path.join(td, "noise.bin")
|
|
157
|
+
out_prefix = os.path.join(td, "out")
|
|
158
|
+
|
|
159
|
+
cap_flat = cap_input[0].astype(np.float32) # (256, 2560)
|
|
160
|
+
with open(emb_path, "wb") as f:
|
|
161
|
+
f.write(struct.pack("i", cap_flat.shape[0]))
|
|
162
|
+
f.write(cap_flat.tobytes())
|
|
163
|
+
|
|
164
|
+
# Same RNG as the Python path so seeds match.
|
|
165
|
+
rng = np.random.default_rng(seed)
|
|
166
|
+
rng.standard_normal((16 * latent * latent), dtype=np.float32).tofile(noise_path)
|
|
167
|
+
|
|
168
|
+
env = dict(os.environ, ZIMAGE_EMB=emb_path, ZIMAGE_NOISE=noise_path)
|
|
169
|
+
# backend: 4 = Metal fp16 (fast, model is fp16-safe), 0 = CPU
|
|
170
|
+
backend = "4" if self._metal_available() else "0"
|
|
171
|
+
cmd = [str(binary), str(self.model_dir), backend, str(num_steps), str(seed),
|
|
172
|
+
out_prefix, "generate"]
|
|
173
|
+
t0 = time.time()
|
|
174
|
+
r = subprocess.run(cmd, capture_output=True, text=True, env=env)
|
|
175
|
+
if r.returncode != 0:
|
|
176
|
+
if verbose:
|
|
177
|
+
print(f" binary denoise failed, falling back to Python: {r.stderr[-200:]}")
|
|
178
|
+
return None
|
|
179
|
+
with open(out_prefix + ".latents.bin", "rb") as f:
|
|
180
|
+
f.read(4) # skip the int count header written by the C++ binary
|
|
181
|
+
latents = np.frombuffer(f.read(), dtype=np.float32)
|
|
182
|
+
if verbose:
|
|
183
|
+
print(f" denoise (C++ binary): {time.time() - t0:.1f}s", flush=True)
|
|
184
|
+
return latents.reshape(1, 16, latent, latent)
|
|
185
|
+
|
|
186
|
+
def _denoise_python(self, cap_input: np.ndarray, num_steps: int, size: int, seed: int,
|
|
187
|
+
verbose: bool = True) -> np.ndarray:
|
|
188
|
+
import MNN.expr as F
|
|
189
|
+
import MNN.nn as nn
|
|
190
|
+
|
|
191
|
+
latent = size // 8
|
|
192
|
+
rng = np.random.default_rng(seed)
|
|
193
|
+
latents = rng.standard_normal((1, 16, latent, latent), dtype=np.float32)
|
|
194
|
+
sigmas = self._make_sigmas(num_steps)
|
|
195
|
+
|
|
196
|
+
use_metal = self._metal_available()
|
|
197
|
+
cfg = ({"backend": "METAL", "precision": "high"} if use_metal
|
|
198
|
+
else {"backend": "CPU", "precision": "normal", "numThread": os.cpu_count() or 4})
|
|
199
|
+
rtmgr = nn.create_runtime_manager((cfg,))
|
|
200
|
+
unet = nn.load_module_from_file(
|
|
201
|
+
str(self.model_dir / "unet.mnn"),
|
|
202
|
+
["sample", "timestep", "encoder_hidden_states"], ["out_sample"], runtime_manager=rtmgr)
|
|
203
|
+
|
|
204
|
+
sv = F.const(latents, list(latents.shape), F.NCHW)
|
|
205
|
+
tv = F.const(np.array([0.5], dtype=np.float32), [1], F.NCHW)
|
|
206
|
+
cv = F.const(cap_input, list(cap_input.shape), F.NCHW)
|
|
207
|
+
unet.forward([sv, tv, cv])[0].read() # warmup / shader compile
|
|
208
|
+
|
|
209
|
+
for i in range(num_steps):
|
|
210
|
+
t0 = time.time()
|
|
211
|
+
t_input = (1000.0 - sigmas[i] * 1000.0) / 1000.0
|
|
212
|
+
sv = F.const(latents, list(latents.shape), F.NCHW)
|
|
213
|
+
tv = F.const(np.array([t_input], dtype=np.float32), [1], F.NCHW)
|
|
214
|
+
out = unet.forward([sv, tv, cv])
|
|
215
|
+
noise_pred = -np.array(out[0].read())
|
|
216
|
+
latents = latents + (sigmas[i + 1] - sigmas[i]) * noise_pred
|
|
217
|
+
if verbose:
|
|
218
|
+
print(f" step {i + 1}/{num_steps}: {time.time() - t0:.2f}s", flush=True)
|
|
219
|
+
return latents
|
|
220
|
+
|
|
221
|
+
def _decode(self, latents: np.ndarray) -> np.ndarray:
|
|
222
|
+
import MNN.expr as F
|
|
223
|
+
import MNN.nn as nn
|
|
224
|
+
|
|
225
|
+
latents = latents / VAE_SCALING_FACTOR + VAE_SHIFT_FACTOR
|
|
226
|
+
rtmgr = nn.create_runtime_manager(
|
|
227
|
+
({"backend": "CPU", "precision": "normal", "numThread": os.cpu_count() or 4},))
|
|
228
|
+
vae = nn.load_module_from_file(
|
|
229
|
+
str(self.model_dir / "vae_decoder.mnn"),
|
|
230
|
+
["latent_sample"], ["sample"], runtime_manager=rtmgr)
|
|
231
|
+
lv = F.const(latents.astype(np.float32), list(latents.shape), F.NCHW)
|
|
232
|
+
out = vae.forward([lv])
|
|
233
|
+
image = np.array(out[0].read())
|
|
234
|
+
image = image[0].transpose(1, 2, 0)
|
|
235
|
+
return np.clip((image + 1) / 2 * 255, 0, 255).astype(np.uint8)
|
|
236
|
+
|
|
237
|
+
# -- public API ------------------------------------------------------
|
|
238
|
+
def __call__(self, prompt: str, size: int = 1024, steps: int = 8, seed: int = 42,
|
|
239
|
+
verbose: bool = True):
|
|
240
|
+
"""Generate one image. Returns a PIL.Image."""
|
|
241
|
+
from PIL import Image
|
|
242
|
+
|
|
243
|
+
t0 = time.time()
|
|
244
|
+
if verbose:
|
|
245
|
+
print(f"Encoding prompt: {prompt!r}", flush=True)
|
|
246
|
+
cap_feats = self._encode_prompt(prompt)
|
|
247
|
+
cap_input = self._pad_cap(cap_feats)
|
|
248
|
+
if verbose:
|
|
249
|
+
print(f"Denoising ({steps} steps, {size}x{size}, seed={seed})...", flush=True)
|
|
250
|
+
latents = self._denoise(cap_input, steps, size, seed, verbose=verbose)
|
|
251
|
+
if verbose:
|
|
252
|
+
print("VAE decoding...", flush=True)
|
|
253
|
+
pixels = self._decode(latents)
|
|
254
|
+
if verbose:
|
|
255
|
+
print(f"Done in {time.time() - t0:.1f}s", flush=True)
|
|
256
|
+
return Image.fromarray(pixels)
|
|
257
|
+
|
|
258
|
+
def generate(self, prompt: str, num_images: int = 1, size: int = 1024, steps: int = 8,
|
|
259
|
+
seed: int = 42, verbose: bool = True):
|
|
260
|
+
"""Generate multiple images (varying seed). Returns a list of PIL.Image."""
|
|
261
|
+
return [self(prompt, size=size, steps=steps, seed=seed + i, verbose=verbose)
|
|
262
|
+
for i in range(num_images)]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zimage-mnn
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Z-Image-Turbo text-to-image generation on MNN (Apple Silicon Metal / CPU)
|
|
5
|
+
Author: ws
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/alibaba/MNN
|
|
8
|
+
Keywords: mnn,diffusion,text-to-image,z-image,apple-silicon
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
|
+
Classifier: Operating System :: MacOS
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Requires-Dist: numpy
|
|
16
|
+
Requires-Dist: pillow
|
|
17
|
+
Requires-Dist: jinja2
|
|
18
|
+
Requires-Dist: mnn>=3.6.0
|
|
19
|
+
Requires-Dist: transformers>=4.40
|
|
20
|
+
Requires-Dist: huggingface_hub
|
|
21
|
+
|
|
22
|
+
# zimage-mnn
|
|
23
|
+
|
|
24
|
+
Run [Z-Image-Turbo](https://huggingface.co/Tongyi-MAI/Z-Image-Turbo) text-to-image locally with
|
|
25
|
+
[MNN](https://github.com/alibaba/MNN) — optimized for Apple Silicon (Metal) with a CPU fallback.
|
|
26
|
+
|
|
27
|
+
The 12.3B-parameter DiT is quantized to int8 weights and runs the full pipeline
|
|
28
|
+
(text encoder → 8-step denoiser → VAE) entirely on-device. No cloud calls.
|
|
29
|
+
|
|
30
|
+
## Install & run (one line)
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
uvx zimage-mnn "a cute cat sitting on a windowsill"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Models (~11 GB) download automatically on first run and are cached in `~/.cache/zimage_mnn`.
|
|
37
|
+
|
|
38
|
+
## CLI usage
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# basic
|
|
42
|
+
uvx zimage-mnn "a red sports car on a coastal highway"
|
|
43
|
+
|
|
44
|
+
# options
|
|
45
|
+
uvx zimage-mnn "a panda eating bamboo" \
|
|
46
|
+
--size 1024 \ # resolution (multiple of 16; model tuned for 1024)
|
|
47
|
+
--steps 8 \ # denoising steps (2-12)
|
|
48
|
+
--seed 42 \ # random seed
|
|
49
|
+
--num-images 2 \ # generate several (seed increments)
|
|
50
|
+
--output out.png \ # output file / prefix
|
|
51
|
+
--backend metal # auto | metal | cpu
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## HTTP API
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
uvx zimage-mnn --api --port 8000
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
curl -X POST http://127.0.0.1:8000/generate \
|
|
62
|
+
-H "Content-Type: application/json" \
|
|
63
|
+
-d '{"prompt":"a cute cat","size":1024,"steps":8,"seed":42}' \
|
|
64
|
+
-o cat.png
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`GET /health` returns `{"status":"ok"}`.
|
|
68
|
+
|
|
69
|
+
## Python API
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from zimage_mnn import ZImageMNNPipeline
|
|
73
|
+
|
|
74
|
+
pipe = ZImageMNNPipeline.from_pretrained() # downloads models
|
|
75
|
+
image = pipe("a cute cat", size=1024, steps=8, seed=42)
|
|
76
|
+
image.save("cat.png")
|
|
77
|
+
|
|
78
|
+
images = pipe.generate("a dog", num_images=3, seed=7)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Performance (Apple M5 Pro, 1024×1024, 8 steps)
|
|
82
|
+
|
|
83
|
+
| Backend | per step | end-to-end |
|
|
84
|
+
|---------|----------|------------|
|
|
85
|
+
| Metal (fp32, int8 weights) | ~13 s | ~2 min |
|
|
86
|
+
| CPU (int8, 12 threads) | ~27 s | ~4 min |
|
|
87
|
+
|
|
88
|
+
Quality matches the original PyTorch pipeline (single-step cosine ≈ 0.9996).
|
|
89
|
+
|
|
90
|
+
## Requirements
|
|
91
|
+
|
|
92
|
+
- Python ≥ 3.9
|
|
93
|
+
- macOS with Apple Silicon recommended (Metal). CPU works anywhere but is slower.
|
|
94
|
+
- ~16 GB RAM recommended; ~11 GB disk for the models.
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
Apache-2.0. Model weights derive from
|
|
99
|
+
[Tongyi-MAI/Z-Image-Turbo](https://huggingface.co/Tongyi-MAI/Z-Image-Turbo) — see its license.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
zimage_mnn/__init__.py
|
|
4
|
+
zimage_mnn/api.py
|
|
5
|
+
zimage_mnn/cli.py
|
|
6
|
+
zimage_mnn/pipeline.py
|
|
7
|
+
zimage_mnn.egg-info/PKG-INFO
|
|
8
|
+
zimage_mnn.egg-info/SOURCES.txt
|
|
9
|
+
zimage_mnn.egg-info/dependency_links.txt
|
|
10
|
+
zimage_mnn.egg-info/entry_points.txt
|
|
11
|
+
zimage_mnn.egg-info/requires.txt
|
|
12
|
+
zimage_mnn.egg-info/top_level.txt
|
|
13
|
+
zimage_mnn/bin/libMNN.dylib
|
|
14
|
+
zimage_mnn/bin/libMNN_Express.dylib
|
|
15
|
+
zimage_mnn/bin/zimage_mnn
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
zimage_mnn
|