OppaAI commited on
Commit
ca9c15d
·
1 Parent(s): 970a61a

feat: add Modal deployment for FLUX.2 klein 9B image generation endpoint

Browse files
Files changed (1) hide show
  1. backend/flux2-klein.py +219 -0
backend/flux2-klein.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ flux-klein.py — FLUX.2 [klein] 9B image generation endpoint for Aiko
3
+ Modal app: oppa-ai-org--aiko-imagegen
4
+
5
+ Supports:
6
+ - Text-to-image (no reference_images)
7
+ - Multi-reference image-to-image (pass 1-2 base64 PNG/JPG strings)
8
+
9
+ Requires Modal secrets:
10
+ - huggingface-secret (HF_TOKEN)
11
+ """
12
+
13
+ import io
14
+ import base64
15
+ import modal
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # image
19
+ # ---------------------------------------------------------------------------
20
+ image = (
21
+ modal.Image.debian_slim(python_version="3.11")
22
+ .apt_install("git")
23
+ .pip_install(
24
+ "torch==2.6.0",
25
+ "torchvision",
26
+ extra_index_url="https://download.pytorch.org/whl/cu124",
27
+ )
28
+ .pip_install(
29
+ "git+https://github.com/huggingface/diffusers.git",
30
+ "transformers",
31
+ "accelerate",
32
+ "huggingface_hub",
33
+ "sentencepiece",
34
+ "Pillow",
35
+ "fastapi[standard]",
36
+ )
37
+ .env({"DIFFUSERS_NO_FLASH_ATTN": "1"})
38
+ )
39
+
40
+ volume = modal.Volume.from_name("aiko-imagegen-weights", create_if_missing=True)
41
+ WEIGHTS_DIR = "/weights"
42
+ MODEL_ID = "black-forest-labs/FLUX.2-klein-9B"
43
+
44
+ app = modal.App("aiko-imagegen", image=image)
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # one-time weight downloader — run with: modal run flux-klein.py::download_weights
48
+ # ---------------------------------------------------------------------------
49
+ @app.function(
50
+ image=image,
51
+ gpu="H100",
52
+ secrets=[modal.Secret.from_name("huggingface-secret")],
53
+ volumes={WEIGHTS_DIR: volume},
54
+ timeout=3600,
55
+ )
56
+ def download_weights():
57
+ import os
58
+ from huggingface_hub import snapshot_download
59
+
60
+ hf_token = os.environ["HF_TOKEN"]
61
+ local_path = f"{WEIGHTS_DIR}/flux2-klein-9b"
62
+
63
+ print("Downloading FLUX.2 klein 9B weights...")
64
+ snapshot_download(
65
+ MODEL_ID,
66
+ local_dir=local_path,
67
+ token=hf_token,
68
+ ignore_patterns=["*.msgpack", "*.h5"],
69
+ )
70
+ volume.commit()
71
+ print("Done. Volume committed.")
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # model class
76
+ # ---------------------------------------------------------------------------
77
+ @app.cls(
78
+ gpu="H100",
79
+ secrets=[modal.Secret.from_name("huggingface-secret")],
80
+ volumes={WEIGHTS_DIR: volume},
81
+ timeout=120,
82
+ scaledown_window=300,
83
+ )
84
+ @modal.concurrent(max_inputs=1)
85
+ class AikoImageGen:
86
+
87
+ @modal.enter()
88
+ def load(self):
89
+ import os
90
+ import torch
91
+ from diffusers import Flux2KleinPipeline
92
+
93
+ local_path = f"{WEIGHTS_DIR}/flux2-klein-9b"
94
+ shard_check = f"{local_path}/transformer/diffusion_pytorch_model-00001-of-00002.safetensors"
95
+
96
+ if not os.path.exists(local_path) or not os.path.exists(shard_check):
97
+ from huggingface_hub import snapshot_download
98
+ hf_token = os.environ["HF_TOKEN"]
99
+ print("Weights missing or incomplete — downloading...")
100
+ snapshot_download(
101
+ MODEL_ID,
102
+ local_dir=local_path,
103
+ token=hf_token,
104
+ ignore_patterns=["*.msgpack", "*.h5"],
105
+ )
106
+ volume.commit()
107
+ else:
108
+ print("Weights cached, loading from volume...")
109
+
110
+ self.pipe = Flux2KleinPipeline.from_pretrained(
111
+ local_path,
112
+ torch_dtype=torch.bfloat16,
113
+ ).to("cuda")
114
+
115
+ print("FLUX.2 klein 9B ready.")
116
+
117
+ @modal.method()
118
+ def generate(
119
+ self,
120
+ prompt: str,
121
+ width: int = 1024,
122
+ height: int = 1024,
123
+ steps: int = 4,
124
+ guidance_scale: float = 1.0,
125
+ seed: int = -1,
126
+ reference_images: list[str] | None = None, # base64-encoded PNG/JPG strings
127
+ ) -> str:
128
+ """Generate image, return base64-encoded PNG string."""
129
+ import torch
130
+ from PIL import Image
131
+
132
+ generator = None
133
+ if seed >= 0:
134
+ generator = torch.Generator(device="cuda").manual_seed(seed)
135
+
136
+ # decode reference images if provided
137
+ ref_pil_images = []
138
+ if reference_images:
139
+ for b64 in reference_images:
140
+ img_bytes = base64.b64decode(b64)
141
+ img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
142
+ ref_pil_images.append(img)
143
+
144
+ kwargs = dict(
145
+ prompt=prompt,
146
+ width=width,
147
+ height=height,
148
+ num_inference_steps=steps,
149
+ guidance_scale=guidance_scale,
150
+ generator=generator,
151
+ )
152
+
153
+ if ref_pil_images:
154
+ # FLUX.2 klein i2i: pass as `image` (single) or `images` (multi-reference)
155
+ if len(ref_pil_images) == 1:
156
+ kwargs["image"] = ref_pil_images[0]
157
+ else:
158
+ kwargs["image"] = ref_pil_images # multi-reference
159
+
160
+ result = self.pipe(**kwargs)
161
+ image = result.images[0]
162
+
163
+ buf = io.BytesIO()
164
+ image.save(buf, format="PNG")
165
+ return base64.b64encode(buf.getvalue()).decode("utf-8")
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # FastAPI wrapper
170
+ # ---------------------------------------------------------------------------
171
+ from fastapi import FastAPI, HTTPException
172
+ from pydantic import BaseModel
173
+ from typing import Optional
174
+
175
+ web_app = FastAPI()
176
+
177
+
178
+ class GenerateRequest(BaseModel):
179
+ prompt: str
180
+ width: int = 1024
181
+ height: int = 1024
182
+ steps: int = 4
183
+ guidance_scale: float = 1.0
184
+ seed: int = -1
185
+ reference_images: Optional[list[str]] = None # base64 strings
186
+
187
+
188
+ class GenerateResponse(BaseModel):
189
+ image_b64: str
190
+ prompt: str
191
+
192
+
193
+ @app.function(image=image)
194
+ @modal.asgi_app()
195
+ def fastapi_app():
196
+ model = AikoImageGen()
197
+
198
+ @web_app.post("/generate", response_model=GenerateResponse)
199
+ async def generate(req: GenerateRequest):
200
+ if not req.prompt.strip():
201
+ raise HTTPException(status_code=400, detail="prompt is required")
202
+
203
+ image_b64 = await model.generate.remote.aio(
204
+ prompt=req.prompt,
205
+ width=req.width,
206
+ height=req.height,
207
+ steps=req.steps,
208
+ guidance_scale=req.guidance_scale,
209
+ seed=req.seed,
210
+ reference_images=req.reference_images,
211
+ )
212
+ return GenerateResponse(image_b64=image_b64, prompt=req.prompt)
213
+
214
+ @web_app.get("/health")
215
+ async def health():
216
+ return {"status": "ok", "model": "FLUX.2-klein-9B"}
217
+
218
+ return web_app
219
+