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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """zimage-mnn: Z-Image-Turbo text-to-image on MNN."""
2
+ from .pipeline import DEFAULT_REPO, ZImageMNNPipeline
3
+
4
+ __all__ = ["ZImageMNNPipeline", "DEFAULT_REPO"]
5
+ __version__ = "0.1.0"
@@ -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()
@@ -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,2 @@
1
+ [console_scripts]
2
+ zimage-mnn = zimage_mnn.cli:main
@@ -0,0 +1,6 @@
1
+ numpy
2
+ pillow
3
+ jinja2
4
+ mnn>=3.6.0
5
+ transformers>=4.40
6
+ huggingface_hub
@@ -0,0 +1 @@
1
+ zimage_mnn