FlameF0X commited on
Commit
ab0b344
Β·
verified Β·
1 Parent(s): 4649ea9

Upload inference.py

Browse files
Files changed (1) hide show
  1. inference.py +670 -0
inference.py ADDED
@@ -0,0 +1,670 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ShellD (Shell Diffusion) - Standalone Inference
3
+ ================================================
4
+ Generate 256x256 images from text prompts using a pre-trained ShellD model.
5
+
6
+ This file is fully self-contained β€” it does NOT import from train.py.
7
+ It duplicates only the architecture classes needed for inference, with all
8
+ training-specific code (dataset, optimizers, VAE pretraining, git cloning, etc.) removed.
9
+
10
+ Usage (load from Hugging Face):
11
+ from inference import ShellDInference
12
+
13
+ pipe = ShellDInference("FlameF0X/ShellD")
14
+ img = pipe.generate("a mountain lake at sunset")
15
+ img.save("output.png")
16
+
17
+ Usage (load from local directory):
18
+ pipe = ShellDInference("./ShellD_model")
19
+ """
20
+
21
+ import json
22
+ import math
23
+ from dataclasses import dataclass, asdict
24
+ from pathlib import Path
25
+ from typing import Dict, Any, List, Optional, Tuple
26
+
27
+ import torch
28
+ import torch.nn as nn
29
+ from huggingface_hub import snapshot_download
30
+ import torch.nn.functional as F
31
+ from safetensors.torch import load_file
32
+ from sentence_transformers import SentenceTransformer
33
+ from PIL import Image
34
+ import numpy as np
35
+
36
+
37
+ # ═══════════════════════════════════════════════════════════
38
+ # Config
39
+ # ═══════════════════════════════════════════════════════════
40
+
41
+ @dataclass
42
+ class ShellDConfig:
43
+ """Model hyperparameters. Must match the saved config.json."""
44
+ image_size: int = 256
45
+ latent_dim: int = 16
46
+ ae_hidden_dim: int = 64
47
+ ae_num_blocks: int = 3
48
+ hidden_dim: int = 256
49
+ num_hidden_layers: int = 12
50
+ num_heads: int = 8
51
+ patch_size: int = 4
52
+ text_encoder_name: str = "./all-MiniLM-L6-v2"
53
+ text_encoder_dim: int = 384
54
+ num_timesteps: int = 1000
55
+ beta_start: float = 1e-4
56
+ beta_end: float = 0.02
57
+ model_name: str = "ShellD"
58
+
59
+ @classmethod
60
+ def from_json(cls, path: str) -> "ShellDConfig":
61
+ with open(path) as f:
62
+ d = json.load(f)
63
+ # Strip keys starting with "_"
64
+ d = {k: v for k, v in d.items() if not k.startswith("_")}
65
+ return cls(**d)
66
+
67
+
68
+ # ═══════════════════════════════════════════════════════════
69
+ # VAE β€” Encoder / Decoder
70
+ # ═══════════════════════════════════════════════════════════
71
+
72
+ class ResidualBlock(nn.Module):
73
+ def __init__(self, in_ch: int, out_ch: int):
74
+ super().__init__()
75
+ self.conv1 = nn.Conv2d(in_ch, out_ch, 3, padding=1)
76
+ self.norm1 = nn.GroupNorm(8, out_ch)
77
+ self.conv2 = nn.Conv2d(out_ch, out_ch, 3, padding=1)
78
+ self.norm2 = nn.GroupNorm(8, out_ch)
79
+ self.skip = nn.Conv2d(in_ch, out_ch, 1) if in_ch != out_ch else nn.Identity()
80
+
81
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
82
+ residual = self.skip(x)
83
+ x = F.silu(self.norm1(self.conv1(x)))
84
+ x = self.norm2(self.conv2(x))
85
+ return F.silu(x + residual)
86
+
87
+
88
+ class Encoder(nn.Module):
89
+ def __init__(self, cfg: ShellDConfig):
90
+ super().__init__()
91
+ self.cfg = cfg
92
+ self.init_conv = nn.Conv2d(3, cfg.ae_hidden_dim, 3, padding=1)
93
+
94
+ blocks = []
95
+ ch = cfg.ae_hidden_dim
96
+ for _ in range(cfg.ae_num_blocks):
97
+ out_ch = min(ch * 2, 256)
98
+ blocks.append(
99
+ nn.Sequential(
100
+ ResidualBlock(ch, out_ch),
101
+ ResidualBlock(out_ch, out_ch),
102
+ nn.Conv2d(out_ch, out_ch, 4, stride=2, padding=1), # downsample
103
+ )
104
+ )
105
+ ch = out_ch
106
+ self.down_blocks = nn.ModuleList(blocks)
107
+
108
+ self.mid = nn.Sequential(
109
+ ResidualBlock(ch, ch),
110
+ ResidualBlock(ch, ch),
111
+ )
112
+ self.out_conv = nn.Conv2d(ch, cfg.latent_dim * 2, 3, padding=1)
113
+
114
+ def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
115
+ x = self.init_conv(x)
116
+ for block in self.down_blocks:
117
+ x = block(x)
118
+ x = self.mid(x)
119
+ x = self.out_conv(x)
120
+ mean, logvar = x.chunk(2, dim=1)
121
+ return mean, logvar
122
+
123
+
124
+ class Decoder(nn.Module):
125
+ def __init__(self, cfg: ShellDConfig):
126
+ super().__init__()
127
+ self.cfg = cfg
128
+ ch = cfg.ae_hidden_dim * (2 ** cfg.ae_num_blocks)
129
+ self.init_conv = nn.Conv2d(cfg.latent_dim, ch, 3, padding=1)
130
+
131
+ self.mid = nn.Sequential(
132
+ ResidualBlock(ch, ch),
133
+ ResidualBlock(ch, ch),
134
+ )
135
+
136
+ up_blocks = []
137
+ for _ in range(cfg.ae_num_blocks):
138
+ out_ch = ch // 2
139
+ up_blocks.append(
140
+ nn.Sequential(
141
+ ResidualBlock(ch, out_ch),
142
+ ResidualBlock(out_ch, out_ch),
143
+ nn.Upsample(scale_factor=2, mode="nearest"),
144
+ )
145
+ )
146
+ ch = out_ch
147
+ self.up_blocks = nn.ModuleList(up_blocks)
148
+
149
+ self.out_conv = nn.Sequential(
150
+ ResidualBlock(ch, ch),
151
+ nn.Conv2d(ch, 3, 3, padding=1),
152
+ nn.Sigmoid(),
153
+ )
154
+
155
+ def forward(self, z: torch.Tensor) -> torch.Tensor:
156
+ x = self.init_conv(z)
157
+ x = self.mid(x)
158
+ for block in self.up_blocks:
159
+ x = block(x)
160
+ return self.out_conv(x)
161
+
162
+
163
+ class VAE(nn.Module):
164
+ def __init__(self, cfg: ShellDConfig):
165
+ super().__init__()
166
+ self.cfg = cfg
167
+ self.encoder = Encoder(cfg)
168
+ self.decoder = Decoder(cfg)
169
+
170
+ def encode(self, x: torch.Tensor) -> torch.Tensor:
171
+ mean, logvar = self.encoder(x)
172
+ logvar = logvar.clamp(-10.0, 10.0)
173
+ std = torch.exp(0.5 * logvar)
174
+ eps = torch.randn_like(std)
175
+ return mean + eps * std
176
+
177
+ def decode(self, z: torch.Tensor) -> torch.Tensor:
178
+ return self.decoder(z)
179
+
180
+ def forward(self, x: torch.Tensor):
181
+ """Full forward (used only for training; included for completeness)."""
182
+ mean, logvar = self.encoder(x)
183
+ logvar = logvar.clamp(-10.0, 10.0)
184
+ std = torch.exp(0.5 * logvar)
185
+ eps = torch.randn_like(std)
186
+ z = mean + eps * std
187
+ return self.decode(z)
188
+
189
+
190
+ # ═══════════════════════════════════════════════════════════
191
+ # DiT Backbone
192
+ # ═══════════════════════════════════════════════════════════
193
+
194
+ class PatchEmbed(nn.Module):
195
+ def __init__(self, cfg: ShellDConfig):
196
+ super().__init__()
197
+ self.cfg = cfg
198
+ self.latent_size = cfg.image_size // (2 ** cfg.ae_num_blocks)
199
+ self.num_patches_1d = self.latent_size // cfg.patch_size
200
+ self.num_patches = self.num_patches_1d ** 2
201
+ self.patch_dim = cfg.latent_dim * (cfg.patch_size ** 2)
202
+ self.proj = nn.Linear(self.patch_dim, cfg.hidden_dim)
203
+
204
+ def forward(self, z: torch.Tensor) -> torch.Tensor:
205
+ B, C, H, W = z.shape
206
+ ps = self.cfg.patch_size
207
+ n = self.num_patches_1d
208
+ z = z.reshape(B, C, n, ps, n, ps)
209
+ z = z.permute(0, 2, 4, 1, 3, 5).reshape(B, self.num_patches, self.patch_dim)
210
+ return self.proj(z)
211
+
212
+
213
+ class DiTBlock(nn.Module):
214
+ def __init__(self, cfg: ShellDConfig):
215
+ super().__init__()
216
+ dim = cfg.hidden_dim
217
+ self.norm1 = nn.LayerNorm(dim)
218
+ self.attn = nn.MultiheadAttention(dim, cfg.num_heads, batch_first=True)
219
+ self.norm_cross = nn.LayerNorm(dim)
220
+ self.cross_attn = nn.MultiheadAttention(dim, cfg.num_heads, batch_first=True)
221
+ self.text_proj = nn.Linear(cfg.text_encoder_dim, dim)
222
+ self.norm2 = nn.LayerNorm(dim)
223
+ self.mlp = nn.Sequential(
224
+ nn.Linear(dim, dim * 4),
225
+ nn.GELU(),
226
+ nn.Linear(dim * 4, dim),
227
+ )
228
+ self.timestep_mlp = nn.Sequential(
229
+ nn.Linear(dim, dim * 4),
230
+ nn.SiLU(),
231
+ nn.Linear(dim * 4, dim),
232
+ )
233
+
234
+ def forward(self, x: torch.Tensor, text_emb: torch.Tensor, t_emb: torch.Tensor):
235
+ # Self-attention
236
+ h = self.norm1(x)
237
+ attn_out, _ = self.attn(h, h, h)
238
+ x = x + attn_out
239
+ # Cross-attention with text
240
+ text_proj = self.text_proj(text_emb)
241
+ h = self.norm_cross(x)
242
+ cross_out, _ = self.cross_attn(h, text_proj, text_proj)
243
+ x = x + cross_out
244
+ # Timestep conditioning
245
+ t_proj = self.timestep_mlp(t_emb)
246
+ x = x + t_proj.unsqueeze(1)
247
+ # MLP
248
+ h = self.norm2(x)
249
+ x = x + self.mlp(h)
250
+ return x
251
+
252
+
253
+ class DiT(nn.Module):
254
+ def __init__(self, cfg: ShellDConfig):
255
+ super().__init__()
256
+ self.cfg = cfg
257
+ self.latent_size = cfg.image_size // (2 ** cfg.ae_num_blocks)
258
+ self.num_patches_1d = self.latent_size // cfg.patch_size
259
+ self.num_patches = self.num_patches_1d ** 2
260
+
261
+ self.patch_embed = PatchEmbed(cfg)
262
+ self.pos_embed = nn.Parameter(torch.randn(1, self.num_patches, cfg.hidden_dim))
263
+ self.blocks = nn.ModuleList([DiTBlock(cfg) for _ in range(cfg.num_hidden_layers)])
264
+ self.norm = nn.LayerNorm(cfg.hidden_dim)
265
+ self.out_proj = nn.Linear(cfg.hidden_dim, cfg.latent_dim * (cfg.patch_size ** 2))
266
+
267
+ self.time_mlp = nn.Sequential(
268
+ nn.Linear(cfg.hidden_dim, cfg.hidden_dim * 4),
269
+ nn.SiLU(),
270
+ nn.Linear(cfg.hidden_dim * 4, cfg.hidden_dim),
271
+ )
272
+
273
+ @staticmethod
274
+ def timestep_embedding(t: torch.Tensor, dim: int, max_period: int = 10000):
275
+ half = dim // 2
276
+ freqs = torch.exp(
277
+ -math.log(max_period) * torch.arange(0, half, dtype=torch.float32) / half
278
+ ).to(t.device)
279
+ args = t[:, None].float() * freqs[None]
280
+ emb = torch.cat([torch.cos(args), torch.sin(args)], dim=-1)
281
+ if dim % 2 == 1:
282
+ emb = torch.cat([emb, torch.zeros_like(emb[:, :1])], dim=-1)
283
+ return emb
284
+
285
+ def forward(self, z: torch.Tensor, text_emb: torch.Tensor, t: torch.Tensor):
286
+ B = z.shape[0]
287
+ dim = self.cfg.hidden_dim
288
+ t_emb = self.timestep_embedding(t, dim)
289
+ t_emb = self.time_mlp(t_emb)
290
+
291
+ x = self.patch_embed(z)
292
+ x = x + self.pos_embed
293
+
294
+ for blk in self.blocks:
295
+ x = blk(x, text_emb, t_emb)
296
+
297
+ x = self.norm(x)
298
+ x = self.out_proj(x)
299
+
300
+ ps = self.cfg.patch_size
301
+ H = W = self.latent_size
302
+ n = self.num_patches_1d
303
+ x = x.reshape(B, n, n, self.cfg.latent_dim, ps, ps)
304
+ x = x.permute(0, 3, 1, 4, 2, 5).reshape(B, self.cfg.latent_dim, H, W)
305
+ return x
306
+
307
+
308
+ # ═══════════════════════════════════════════════════════════
309
+ # Full ShellD Model (Inference-only)
310
+ # ═══════════════════════════════════════════════════════════
311
+
312
+ class ShellDModel(nn.Module):
313
+ """ShellD model for inference. Minimal β€” no training helpers."""
314
+
315
+ def __init__(self, cfg: ShellDConfig):
316
+ super().__init__()
317
+ self.cfg = cfg
318
+ self.vae = VAE(cfg)
319
+ self.dit = DiT(cfg)
320
+ self.text_encoder = None # loaded separately
321
+
322
+ def encode_text(self, prompts: List[str], device: torch.device) -> torch.Tensor:
323
+ assert self.text_encoder is not None, "Text encoder not loaded"
324
+ with torch.no_grad():
325
+ emb = self.text_encoder.encode(prompts, convert_to_tensor=True)
326
+ return emb.to(device).unsqueeze(1) # [B, 1, 384]
327
+
328
+ def forward(self, z: torch.Tensor, text_emb: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
329
+ """Predict the noise at timestep t given noisy latent z and text."""
330
+ return self.dit(z, text_emb, t)
331
+
332
+
333
+ # ═══════════════════════════════════════════════════════════
334
+ # Diffusion Schedule (Reverse Process Only)
335
+ # ═══════════════════════════════════════════════════════════
336
+
337
+ class DiffusionSchedule:
338
+ """DDPM schedule for the reverse (denoising) process."""
339
+
340
+ def __init__(self, cfg: ShellDConfig, device: torch.device):
341
+ betas = torch.linspace(cfg.beta_start, cfg.beta_end, cfg.num_timesteps, device=device)
342
+ alphas = 1.0 - betas
343
+ alphas_cumprod = torch.cumprod(alphas, dim=0)
344
+
345
+ self.betas = betas
346
+ self.alphas = alphas
347
+ self.alphas_cumprod = alphas_cumprod
348
+ self.sqrt_alphas_cumprod = alphas_cumprod.sqrt()
349
+ self.sqrt_one_minus_alphas_cumprod = (1.0 - alphas_cumprod).sqrt()
350
+
351
+ @torch.no_grad()
352
+ def sample(
353
+ self,
354
+ model: ShellDModel,
355
+ text_emb: torch.Tensor,
356
+ num_steps: Optional[int] = None,
357
+ cfg_scale: float = 3.0,
358
+ seed: Optional[int] = None,
359
+ ) -> torch.Tensor:
360
+ """
361
+ DDPM reverse sampling with optional classifier-free guidance.
362
+
363
+ Args:
364
+ model: The ShellD model.
365
+ text_emb: Text embedding [B, 1, 384].
366
+ num_steps: Number of denoising steps (default: cfg.num_timesteps).
367
+ cfg_scale: Classifier-free guidance scale. 1.0 = no guidance.
368
+ seed: Optional random seed for reproducibility.
369
+
370
+ Returns:
371
+ Denoised latent tensor [B, latent_dim, H', W'].
372
+ """
373
+ if seed is not None:
374
+ torch.manual_seed(seed)
375
+
376
+ device = text_emb.device
377
+ B = text_emb.shape[0]
378
+ cfg = model.cfg
379
+
380
+ num_steps = num_steps or cfg.num_timesteps
381
+
382
+ # Latent spatial size
383
+ H = W = cfg.image_size // (2 ** cfg.ae_num_blocks)
384
+
385
+ # Start from random noise
386
+ z = torch.randn(B, cfg.latent_dim, H, W, device=device)
387
+
388
+ # For classifier-free guidance, we need an unconditional embedding (zeros)
389
+ if cfg_scale != 1.0:
390
+ uncond_emb = torch.zeros_like(text_emb)
391
+
392
+ # Time step resampling for faster inference (DDPM-style, evenly spaced)
393
+ step_indices = torch.linspace(0, cfg.num_timesteps - 1, num_steps, device=device, dtype=torch.long)
394
+
395
+ for i in range(num_steps - 1, -1, -1):
396
+ t = step_indices[i]
397
+ t_batch = t.expand(B)
398
+
399
+ # Predict noise
400
+ if cfg_scale != 1.0:
401
+ # Classifier-free guidance: combine conditional and unconditional predictions
402
+ z_in = torch.cat([z, z], dim=0)
403
+ t_in = torch.cat([t_batch, t_batch], dim=0)
404
+ text_in = torch.cat([text_emb, uncond_emb], dim=0)
405
+ noise_pred = model(z_in, text_in, t_in)
406
+ noise_cond, noise_uncond = noise_pred.chunk(2, dim=0)
407
+ noise_pred = noise_uncond + cfg_scale * (noise_cond - noise_uncond)
408
+ else:
409
+ noise_pred = model(z, text_emb, t_batch)
410
+
411
+ # DDPM update step
412
+ alpha = self.alphas[t]
413
+ alpha_cumprod = self.alphas_cumprod[t]
414
+ beta = self.betas[t]
415
+
416
+ # Compute predicted x0 (for logging purposes, not needed for step)
417
+ # x0_pred = (z - sqrt_one_minus_ac * noise_pred) / sqrt_ac
418
+
419
+ # Mean for posterior
420
+ coef1 = 1.0 / alpha.sqrt()
421
+ coef2 = beta / (1.0 - alpha_cumprod).sqrt()
422
+ z_mean = coef1 * (z - coef2 * noise_pred)
423
+
424
+ if i > 0:
425
+ noise = torch.randn_like(z)
426
+ z = z_mean + beta.sqrt() * noise
427
+ else:
428
+ z = z_mean
429
+
430
+ return z
431
+
432
+
433
+ # ═══════════════════════════════════════════════════════════
434
+ # High-Level Inference Pipeline
435
+ # ═══════════════════════════════════════════════════════════
436
+
437
+ class ShellDInference:
438
+ """
439
+ High-level pipeline for ShellD text-to-image generation.
440
+
441
+ Usage:
442
+ pipe = ShellDInference("./ShellD_model")
443
+ img = pipe.generate("a cat sitting on a mat")
444
+ img.save("cat.png")
445
+ """
446
+
447
+ def __init__(
448
+ self,
449
+ model_dir: str,
450
+ device: Optional[str] = None,
451
+ text_encoder_path: Optional[str] = None,
452
+ ):
453
+ """
454
+ Args:
455
+ model_dir: Path to the model directory, or a Hugging Face repo ID
456
+ (e.g. "FlameF0X/ShellD"). If a repo ID is given, the
457
+ weights are automatically downloaded via huggingface_hub.
458
+ device: Device to run on ('cuda', 'cpu', or None for auto-detect).
459
+ text_encoder_path: Optional override path for the text encoder model.
460
+ Defaults to the path in config.json.
461
+ """
462
+ self.device = torch.device(
463
+ device or ("cuda" if torch.cuda.is_available() else "cpu")
464
+ )
465
+ print(f"ShellD inference using device: {self.device}")
466
+
467
+ # Resolve model source: Hugging Face repo or local path
468
+ local_path = Path(model_dir)
469
+ if local_path.exists():
470
+ # Local directory
471
+ self.model_dir = local_path
472
+ else:
473
+ # Assume it's a Hugging Face repo ID β€” download via hub
474
+ print(f"Downloading model from Hugging Face: {model_dir}")
475
+ self.model_dir = Path(snapshot_download(repo_id=model_dir))
476
+ print(f"Model cached at: {self.model_dir}")
477
+
478
+ # 1. Load config
479
+ config_path = self.model_dir / "config.json"
480
+ if not config_path.exists():
481
+ raise FileNotFoundError(f"config.json not found at {config_path}")
482
+ self.cfg = ShellDConfig.from_json(str(config_path))
483
+
484
+ # 2. Build model
485
+ self.model = ShellDModel(self.cfg).to(self.device)
486
+ self.model.eval()
487
+
488
+ # 3. Load weights
489
+ weights_path = self.model_dir / "model.safetensors"
490
+ if not weights_path.exists():
491
+ raise FileNotFoundError(f"model.safetensors not found at {weights_path}")
492
+ self._load_weights(str(weights_path))
493
+
494
+ # 4. Load text encoder
495
+ encoder_path = text_encoder_path or self.cfg.text_encoder_name
496
+ print(f"Loading text encoder from: {encoder_path}")
497
+ self.model.text_encoder = SentenceTransformer(encoder_path)
498
+
499
+ # 5. Diffusion schedule
500
+ self.diffusion = DiffusionSchedule(self.cfg, self.device)
501
+
502
+ # Print parameter count
503
+ total = sum(p.numel() for p in self.model.parameters())
504
+ trainable = sum(p.numel() for p in self.model.parameters() if p.requires_grad)
505
+ print(f"Model loaded: {total:,} total params ({trainable:,} trainable)")
506
+
507
+ def _load_weights(self, weights_path: str):
508
+ """Load state dict, mapping prefixes to the correct submodules."""
509
+ sd = load_file(weights_path)
510
+
511
+ vae_sd = {k.replace("vae.", ""): v for k, v in sd.items() if k.startswith("vae.")}
512
+ dit_sd = {k.replace("dit.", ""): v for k, v in sd.items() if k.startswith("dit.")}
513
+ txt_sd = {k.replace("text_encoder.", ""): v for k, v in sd.items() if k.startswith("text_encoder.")}
514
+
515
+ self.model.vae.load_state_dict(vae_sd)
516
+ self.model.dit.load_state_dict(dit_sd)
517
+
518
+ if txt_sd and self.model.text_encoder is not None:
519
+ self.model.text_encoder.load_state_dict(txt_sd)
520
+ print("Text encoder weights loaded from safetensors.")
521
+ else:
522
+ print("Text encoder loaded from SentenceTransformer cache (weights not in safetensors).")
523
+
524
+ @torch.no_grad()
525
+ def generate(
526
+ self,
527
+ prompt: str,
528
+ num_steps: int = 250,
529
+ cfg_scale: float = 3.0,
530
+ seed: Optional[int] = None,
531
+ output_size: Optional[int] = None,
532
+ ) -> Image.Image:
533
+ """
534
+ Generate an image from a text prompt.
535
+
536
+ Args:
537
+ prompt: Text description of the desired image.
538
+ num_steps: Number of denoising steps (fewer = faster, lower quality).
539
+ Recommended: 250-1000.
540
+ cfg_scale: Classifier-free guidance scale. Higher = more prompt adherence.
541
+ 1.0 = no guidance. Typical range: 2.0-5.0.
542
+ seed: Random seed for reproducibility.
543
+ output_size: If set, the output image is resized to (output_size, output_size).
544
+
545
+ Returns:
546
+ A PIL Image.
547
+ """
548
+ self.model.eval()
549
+
550
+ # Encode prompt
551
+ text_emb = self.model.encode_text([prompt], self.device) # [1, 1, 384]
552
+
553
+ # Sample latent
554
+ z = self.diffusion.sample(
555
+ self.model,
556
+ text_emb,
557
+ num_steps=num_steps,
558
+ cfg_scale=cfg_scale,
559
+ seed=seed,
560
+ )
561
+
562
+ # Decode latent to image
563
+ img_tensor = self.model.vae.decode(z) # [1, 3, 256, 256], values in [0, 1]
564
+
565
+ # Convert to PIL
566
+ img_np = img_tensor[0].permute(1, 2, 0).cpu().numpy() # [256, 256, 3]
567
+ img_np = (img_np * 255).clip(0, 255).astype(np.uint8)
568
+ img = Image.fromarray(img_np)
569
+
570
+ if output_size is not None:
571
+ img = img.resize((output_size, output_size), Image.Resampling.BICUBIC)
572
+
573
+ return img
574
+
575
+ @torch.no_grad()
576
+ def generate_batch(
577
+ self,
578
+ prompts: List[str],
579
+ num_steps: int = 250,
580
+ cfg_scale: float = 3.0,
581
+ seed: Optional[int] = None,
582
+ ) -> List[Image.Image]:
583
+ """
584
+ Generate images for multiple prompts efficiently (batched).
585
+
586
+ Args:
587
+ prompts: List of text prompts.
588
+ num_steps: Number of denoising steps.
589
+ cfg_scale: Classifier-free guidance scale.
590
+ seed: Random seed (applied per-prompt).
591
+
592
+ Returns:
593
+ List of PIL Images.
594
+ """
595
+ self.model.eval()
596
+ B = len(prompts)
597
+
598
+ # Encode all prompts
599
+ text_emb = self.model.encode_text(prompts, self.device) # [B, 1, 384]
600
+
601
+ # Sample latents
602
+ z = self.diffusion.sample(
603
+ self.model,
604
+ text_emb,
605
+ num_steps=num_steps,
606
+ cfg_scale=cfg_scale,
607
+ seed=seed,
608
+ )
609
+
610
+ # Decode
611
+ img_tensor = self.model.vae.decode(z) # [B, 3, 256, 256]
612
+
613
+ images = []
614
+ for i in range(B):
615
+ img_np = img_tensor[i].permute(1, 2, 0).cpu().numpy()
616
+ img_np = (img_np * 255).clip(0, 255).astype(np.uint8)
617
+ images.append(Image.fromarray(img_np))
618
+
619
+ return images
620
+
621
+
622
+ # ═══════════════════════════════════════════════════════════
623
+ # Command-Line Interface
624
+ # ═══════════════════════════════════════════════════════════
625
+
626
+ def main():
627
+ import argparse
628
+
629
+ parser = argparse.ArgumentParser(description="ShellD - Text-to-Image Generation")
630
+ parser.add_argument("--model_dir", type=str, default="FlameF0X/ShellD",
631
+ help="Path to model directory or Hugging Face repo ID (default: FlameF0X/ShellD)")
632
+ parser.add_argument("--text_encoder", type=str, default=None,
633
+ help="Override path for the text encoder model")
634
+ parser.add_argument("--prompt", type=str, required=True,
635
+ help="Text prompt for image generation")
636
+ parser.add_argument("--output", type=str, default="output.png",
637
+ help="Output image path")
638
+ parser.add_argument("--steps", type=int, default=250,
639
+ help="Number of denoising steps (default: 250)")
640
+ parser.add_argument("--cfg", type=float, default=3.0,
641
+ help="Classifier-free guidance scale (default: 3.0)")
642
+ parser.add_argument("--seed", type=int, default=None,
643
+ help="Random seed for reproducibility")
644
+ parser.add_argument("--device", type=str, default=None,
645
+ help="Device: 'cuda' or 'cpu'")
646
+ parser.add_argument("--size", type=int, default=None,
647
+ help="Output image size (square, resized)")
648
+
649
+ args = parser.parse_args()
650
+
651
+ pipe = ShellDInference(
652
+ model_dir=args.model_dir,
653
+ device=args.device,
654
+ text_encoder_path=args.text_encoder,
655
+ )
656
+
657
+ img = pipe.generate(
658
+ prompt=args.prompt,
659
+ num_steps=args.steps,
660
+ cfg_scale=args.cfg,
661
+ seed=args.seed,
662
+ output_size=args.size,
663
+ )
664
+
665
+ img.save(args.output)
666
+ print(f"Image saved to {args.output}")
667
+
668
+
669
+ if __name__ == "__main__":
670
+ main()