File size: 10,507 Bytes
74bac72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
"""Single-GPU Kelix-AEDIT inference: an image or local VQ indices -> PNG."""
import argparse
import json
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent
# Support running the demo or a downloaded HF script inside a Kelix checkout.
for parent in (ROOT, *ROOT.parents):
    if (parent / 'muse' / '__init__.py').is_file():
        sys.path.insert(0, str(parent))
        break


def parse_args():
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument('--ckpt', default=str(ROOT) if (ROOT / 'config.json').is_file() else 'OpenOneRec/Kelix-AEDIT', help='Local DiT directory or HF repo ID')
    p.add_argument('--tokenizer', default='OpenOneRec/Kelix-SFT', help='Kelix-SFT repo/directory or a standalone image tokenizer directory')
    p.add_argument('--vae', default='Efficient-Large-Model/SANA1.5_1.6B_1024px_diffusers/vae', help='Local DC-AE directory or HF repo/subfolder')
    inputs = p.add_mutually_exclusive_group(required=True)
    inputs.add_argument('--image', help='Image to encode and reconstruct')
    inputs.add_argument('--tokens', help='Safely loaded .pt LongTensor [H*W,8], local codebook indices')
    p.add_argument('--grid', type=int, default=18, help='Condition grid width/height; 18 means 324 tokens')
    p.add_argument('--output', default='output.png')
    p.add_argument('--image-size', type=int, default=512)
    p.add_argument('--steps', type=int, default=20)
    p.add_argument('--cfg', type=float, default=4.5)
    p.add_argument('--flow-shift', type=float, default=3.0)
    p.add_argument('--seed', type=int, default=42)
    p.add_argument('--device', default='cuda:0')
    p.add_argument('--dtype', choices=['bfloat16', 'float16', 'float32'], default='bfloat16')
    return p.parse_args()


def positions(h, w, device):
    import torch
    ids = torch.arange(h * w, device=device)
    return {'height': ids // w, 'width': ids % w}


def resolve_directory(source, allow_patterns=None):
    path = Path(source)
    if path.is_dir():
        return path
    from huggingface_hub import snapshot_download
    parts = str(source).split('/')
    repo_id = '/'.join(parts[:2])
    subfolder = '/'.join(parts[2:])
    patterns = [f'{subfolder}/*'] if subfolder else allow_patterns
    root = Path(snapshot_download(repo_id, allow_patterns=patterns))
    return root / subfolder if subfolder else root


def load_image_tokenizer(source, device, dtype):
    """Load the visual tokenizer without instantiating or downloading the LLM."""
    import torch
    from accelerate import init_empty_weights
    from safetensors import safe_open
    from muse.config.model_config import KeyeTokenizerConfig
    from muse.models.keye_tokenizer import KeyeImageTokenizer

    folder = resolve_directory(source, ['*.json', '*.txt', '*.py', '*.jinja'])
    config = json.loads((folder / 'config.json').read_text())
    if 'tokenizer_config' not in config:
        tokenizer = KeyeImageTokenizer.from_pretrained(
            str(folder), strict=True, fusion_type='mean'
        ).to(device=device, dtype=dtype).eval()
        return tokenizer, folder

    tokenizer_config = KeyeTokenizerConfig(**config['tokenizer_config'])
    tokenizer_config.fusion_type = 'mean'
    with init_empty_weights():
        tokenizer = KeyeImageTokenizer(tokenizer_config)
    index = json.loads((folder / 'model.safetensors.index.json').read_text())
    prefix = 'visual_tokenizer.'
    selected = {k: v for k, v in index['weight_map'].items() if k.startswith(prefix)}
    if not selected:
        raise ValueError('No visual_tokenizer weights found in the checkpoint index')
    state = {}
    for shard in sorted(set(selected.values())):
        path = folder / shard
        if not path.is_file():
            if Path(source).is_dir():
                raise FileNotFoundError(f'Missing tokenizer shard: {shard}')
            from huggingface_hub import hf_hub_download
            path = Path(hf_hub_download(source, shard, revision=folder.name))
        with safe_open(str(path), framework='pt', device='cpu') as weights:
            for key, filename in selected.items():
                if filename == shard:
                    state[key[len(prefix):]] = weights.get_tensor(key)
    tokenizer.load_state_dict(state, strict=True, assign=True)
    tokenizer = tokenizer.to(device=device, dtype=dtype).eval()
    return tokenizer, folder


def main(args):
    import torch
    import torch.nn.functional as F
    from PIL import Image
    from diffusers import AutoencoderDC, FlowMatchEulerDiscreteScheduler
    from transformers import AutoProcessor
    from muse.models.base import Model
    from muse.training.common import set_default_dtype

    if args.grid < 1 or args.steps < 1 or args.image_size < 32 or args.image_size % 32:
        raise ValueError('grid and steps must be positive; image-size must be a positive multiple of 32')
    ckpt = resolve_directory(args.ckpt, ['config.json', '*.safetensors', '*.index.json'])
    dit_dir = ckpt / 'dit' if (ckpt / 'dit').is_dir() else ckpt
    device, dtype = torch.device(args.device), getattr(torch, args.dtype)
    if device.type == 'cuda' and not torch.cuda.is_available():
        raise RuntimeError('CUDA is required for the FlashAttention vision encoder')
    torch.manual_seed(args.seed)
    with torch.inference_mode(), set_default_dtype(dtype):
        print('Loading DiT (strict=True)...', flush=True)
        model = Model.from_pretrained(str(dit_dir), strict=True).to(device=device, dtype=dtype).eval()
        print('Loading image tokenizer (strict=True)...', flush=True)
        tokenizer, tokenizer_dir = load_image_tokenizer(args.tokenizer, device, dtype)
        vae_dir = resolve_directory(args.vae, ['config.json', '*.safetensors'])
        vae = AutoencoderDC.from_pretrained(str(vae_dir), torch_dtype=dtype).to(device).eval()
        length = args.grid ** 2
        if args.tokens:
            indices = torch.load(args.tokens, map_location='cpu', weights_only=True)
            if not isinstance(indices, torch.Tensor) or tuple(indices.shape) != (length, tokenizer.n_q_tokens):
                raise ValueError(f'Expected integer tensor [{length}, {tokenizer.n_q_tokens}]')
            if indices.dtype not in (torch.int32, torch.int64):
                raise ValueError('Token indices must have integer dtype')
            limit = tokenizer.config.codebook_size // tokenizer.config.split_voc
            if indices.min() < 0 or indices.max() >= limit:
                raise ValueError(f'Expected per-codebook indices in [0, {limit}) (not AR vocabulary IDs)')
            cond, mask, seq_len = tokenizer.tokenize_indices(
                indices.to(device=device, dtype=torch.long), max_pad_to=length,
                lengths=[length], return_attention_mask=True,
            )
        else:
            processor = AutoProcessor.from_pretrained(
                str(tokenizer_dir), trust_remote_code=True, use_fast=False,
            )
            # Match the original inference's square resize, then use an exact grid.
            # A merged Keye token covers 2*14 pixels; 18*28=504 for 324 tokens.
            image = Image.open(args.image).convert('RGB').resize(
                (args.image_size, args.image_size), Image.Resampling.LANCZOS,
            ).resize((args.grid * 28, args.grid * 28), Image.Resampling.BICUBIC)
            inputs = processor(text=[''], images=[image], return_tensors='pt')
            actual_grid = inputs['image_grid_thw'][0, 1:] // 2
            if actual_grid.tolist() != [args.grid, args.grid]:
                raise ValueError(f'Unexpected processor grid: {actual_grid.tolist()}')
            encoded = tokenizer(
                pixel_values=inputs['pixel_values'].to(device=device, dtype=dtype),
                image_grid_thw=inputs['image_grid_thw'].to(device),
            )
            features = encoded.get('token_embeds')
            if features is None:
                # Older Kelix releases compute fused features without returning them.
                features = torch.stack([
                    projector(z_q) for projector, z_q in
                    zip(tokenizer.up_projectors, encoded['z_q'])
                ], dim=1).mean(dim=1)
            if features.shape[0] != length:
                raise ValueError(f'Expected {length} image tokens, got {features.shape[0]}')
            cond, seq_len = features[None], length
            mask = torch.ones((1, 1, 1, length), device=device)
        if cond.shape[-1] != model.config.caption_channels:
            raise ValueError('Tokenizer condition dimension does not match the DiT config')
        null = model.y_embedder.y_embedding[:seq_len].to(device=device, dtype=dtype)
        valid = null.shape[0]
        null = F.pad(null, (0, 0, 0, seq_len - valid))[None]
        null_mask = torch.zeros_like(mask)
        null_mask[..., :valid] = 1
        cond_cfg = torch.cat([null, cond])
        mask_cfg = torch.cat([null_mask, mask])
        latent_size = args.image_size // 32
        generator = torch.Generator(device=device).manual_seed(args.seed)
        latents = torch.randn((1, model.config.in_channels, latent_size, latent_size),
                              device=device, dtype=dtype, generator=generator)
        scheduler = FlowMatchEulerDiscreteScheduler(shift=args.flow_shift)
        scheduler.set_timesteps(args.steps, device=device)
        x_pos = positions(latent_size, latent_size, device)
        y_pos = positions(args.grid, args.grid, device)
        print(f'Sampling {args.steps} steps...', flush=True)
        for t in scheduler.timesteps:
            pred = model.forward_with_dpmsolver(
                torch.cat([latents, latents]), t.expand(2), cond_cfg, mask=mask_cfg,
                x_input_pos=x_pos, cond_input_pos=y_pos,
            )
            uncond, conditional = pred.chunk(2)
            pred = uncond + args.cfg * (conditional - uncond)
            latents = scheduler.step(pred, t, latents, return_dict=False)[0]
        image = (vae.decode(latents / vae.config.scaling_factor).sample[0] / 2 + 0.5).clamp(0, 1)
        if not torch.isfinite(image).all():
            raise RuntimeError('Non-finite pixels encountered')
        pixels = (image.float().permute(1, 2, 0).cpu().numpy() * 255).round().astype('uint8')
        output = Path(args.output)
        output.parent.mkdir(parents=True, exist_ok=True)
        Image.fromarray(pixels).save(output)
        print(f'Saved {output.resolve()}')


if __name__ == '__main__':
    main(parse_args())