Any-to-Any
Transformers
English
text-to-image
image-to-image
text-and-image-to-image
multimodal
unified-model
thumbnail-generation
vlm
Instructions to use asats/thumbnail-vlm-janus-pro with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use asats/thumbnail-vlm-janus-pro with Transformers:
# Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("asats/thumbnail-vlm-janus-pro", device_map="auto") - Notebooks
- Google Colab
- Kaggle
| """ | |
| End-to-End Thumbnail VLM Training Pipeline | |
| This script: | |
| 1. Downloads and prepares training data (PosterCraft/Poster100K) | |
| 2. Fine-tunes Janus-Pro-7B for thumbnail generation | |
| 3. Evaluates the model with sample generations | |
| 4. Pushes to HuggingFace Hub | |
| Run with: python run_training.py | |
| """ | |
| import os | |
| import sys | |
| import json | |
| import random | |
| import logging | |
| import io | |
| import base64 | |
| from pathlib import Path | |
| import numpy as np | |
| import torch | |
| import torch.nn.functional as F | |
| from PIL import Image | |
| from tqdm import tqdm | |
| import trackio | |
| logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") | |
| logger = logging.getLogger(__name__) | |
| # βββ Configuration ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| MODEL_PATH = "deepseek-ai/Janus-Pro-7B" | |
| HUB_MODEL_ID = "asats/thumbnail-vlm-janus-pro" | |
| OUTPUT_DIR = "/app/results/janus_thumbnail" | |
| DATA_DIR = "/app/thumbnail_data" | |
| IMAGE_DIR = os.path.join(DATA_DIR, "images") | |
| JSONL_PATH = os.path.join(DATA_DIR, "train.jsonl") | |
| # Training hyperparameters (Janus-4o recipe) | |
| EPOCHS = 3 | |
| BATCH_SIZE = 1 # per device (single GPU) | |
| GRADIENT_ACCUMULATION = 16 # effective batch = 16 | |
| LR = 5e-6 | |
| IMAGE_SIZE = 384 | |
| IMAGE_TOKEN_NUM = 576 | |
| VQ_CODEBOOK_SIZE = 16384 | |
| CFG_MASK_PROB = 0.10 | |
| SAVE_EVERY = 200 | |
| LOG_EVERY = 5 | |
| SEED = 42 | |
| os.makedirs(IMAGE_DIR, exist_ok=True) | |
| os.makedirs(OUTPUT_DIR, exist_ok=True) | |
| # βββ Step 1: Prepare Training Data ββββββββββββββββββββββββββββββββββββββββββ | |
| def prepare_training_data(max_poster_samples=8000, max_synthetic=2000): | |
| """Download PosterCraft images and create JSONL training file.""" | |
| logger.info("=" * 60) | |
| logger.info("Step 1: Preparing Training Data") | |
| logger.info("=" * 60) | |
| if os.path.exists(JSONL_PATH): | |
| with open(JSONL_PATH) as f: | |
| count = sum(1 for _ in f) | |
| logger.info(f"Training data already exists: {count} samples") | |
| return count | |
| from datasets import load_dataset | |
| entries = [] | |
| # 1a. Download PosterCraft/Poster100K images | |
| logger.info(f"Downloading PosterCraft/Poster100K (up to {max_poster_samples} samples)...") | |
| T2I_TEMPLATES = [ | |
| "Generate a professional thumbnail: {caption}", | |
| "Create an eye-catching visual thumbnail: {caption}", | |
| "Design a compelling thumbnail image: {caption}", | |
| "Generate a high-quality thumbnail: {caption}", | |
| "Create a visually striking thumbnail: {caption}", | |
| ] | |
| try: | |
| ds = load_dataset("PosterCraft/Poster100K", split="train", streaming=True) | |
| count = 0 | |
| for sample in tqdm(ds, desc="Downloading posters", total=max_poster_samples): | |
| if count >= max_poster_samples: | |
| break | |
| caption = sample.get("caption", "") | |
| if not caption or len(caption) < 20: | |
| continue | |
| image_data = sample.get("image") | |
| if image_data is None: | |
| continue | |
| fname = f"poster_{count:06d}.jpg" | |
| fpath = os.path.join(IMAGE_DIR, fname) | |
| try: | |
| if isinstance(image_data, bytes): | |
| img = Image.open(io.BytesIO(image_data)) | |
| elif isinstance(image_data, str): | |
| img_bytes = base64.b64decode(image_data) | |
| img = Image.open(io.BytesIO(img_bytes)) | |
| elif isinstance(image_data, Image.Image): | |
| img = image_data | |
| else: | |
| continue | |
| # Resize and save | |
| img = img.convert("RGB") | |
| w, h = img.size | |
| min_dim = min(w, h) | |
| left = (w - min_dim) // 2 | |
| top = (h - min_dim) // 2 | |
| img = img.crop((left, top, left + min_dim, top + min_dim)) | |
| img = img.resize((IMAGE_SIZE, IMAGE_SIZE), Image.LANCZOS) | |
| img.save(fpath, "JPEG", quality=95) | |
| # Truncate long captions | |
| if len(caption) > 500: | |
| caption = caption[:500] + "..." | |
| template = random.choice(T2I_TEMPLATES) | |
| instruction = template.format(caption=caption) | |
| entries.append({ | |
| "instruction": instruction, | |
| "output_image": fname, | |
| }) | |
| count += 1 | |
| if count % 500 == 0: | |
| logger.info(f" Downloaded {count}/{max_poster_samples} posters") | |
| except Exception as e: | |
| continue | |
| except Exception as e: | |
| logger.error(f"Error loading PosterCraft: {e}") | |
| logger.info(f" Downloaded {len(entries)} poster images") | |
| # 1b. Generate synthetic thumbnail prompts | |
| logger.info(f"Generating {max_synthetic} synthetic thumbnail prompts...") | |
| categories = { | |
| "tech": [ | |
| "Professional tech review thumbnail: sleek {product} product shot with dramatic studio lighting, bold comparison graphics, modern gradient background, text overlay '{title}'", | |
| "Technology unboxing thumbnail: hands opening {product} box, excited reaction, bright colorful background, text '{title}'", | |
| ], | |
| "cooking": [ | |
| "Appetizing cooking thumbnail: close-up of {dish} with steam rising, warm golden lighting, rustic kitchen background, text '{title}'", | |
| "Food recipe thumbnail: beautifully plated {dish}, overhead flat-lay shot, marble countertop, fresh ingredients scattered, text '{title}'", | |
| ], | |
| "gaming": [ | |
| "Epic gaming thumbnail: dramatic gameplay scene from {game}, character in action pose, particle effects, neon glow, bold text '{title}'", | |
| "Gaming reaction thumbnail: intense player reaction face, {game} gameplay in background, fire effects, text '{title}'", | |
| ], | |
| "fitness": [ | |
| "Fitness motivation thumbnail: athletic person doing {exercise}, dynamic studio lighting, energetic colors, bold text '{title}'", | |
| "Health transformation thumbnail: before/after split screen, clean modern design, motivational text '{title}'", | |
| ], | |
| "education": [ | |
| "Educational explainer thumbnail: clean whiteboard graphic about {topic}, colorful diagrams, professional font, text '{title}'", | |
| "Science video thumbnail: fascinating visualization of {topic}, bright curiosity-inducing colors, text '{title}'", | |
| ], | |
| "travel": [ | |
| "Travel vlog thumbnail: stunning panoramic view of {place}, golden hour lighting, wanderlust aesthetic, text '{title}'", | |
| "Adventure thumbnail: breathtaking landscape of {place}, person silhouette, dramatic sky, text '{title}'", | |
| ], | |
| } | |
| fill_values = { | |
| "product": ["iPhone 16 Pro", "MacBook Air M4", "PS5 Pro", "Tesla Model Y", "Quest 4", "Galaxy S25"], | |
| "dish": ["pasta carbonara", "ramen bowl", "sushi platter", "chocolate lava cake", "grilled salmon"], | |
| "game": ["Zelda Echoes", "GTA VI", "Minecraft", "Fortnite", "Elden Ring DLC", "Cyberpunk 2078"], | |
| "exercise": ["deadlifts", "yoga flow", "HIIT workout", "marathon training", "calisthenics"], | |
| "topic": ["quantum computing", "AI revolution", "black holes", "climate change", "human brain"], | |
| "place": ["Tokyo streets", "Santorini sunset", "Iceland northern lights", "Bali rice terraces", "Swiss Alps"], | |
| "title": [ | |
| "You NEED To See This!", "GAME CHANGER!", "The Ultimate Guide", "Top 10 SECRETS", | |
| "I Tried This For 30 Days", "Watch Before You Buy!", "Is It Worth It?", | |
| "HONEST Review", "This Changed EVERYTHING", "Don't Make This Mistake!", | |
| ], | |
| } | |
| for i in range(max_synthetic): | |
| cat_name = random.choice(list(categories.keys())) | |
| template = random.choice(categories[cat_name]) | |
| prompt = template | |
| for key, values in fill_values.items(): | |
| placeholder = "{" + key + "}" | |
| if placeholder in prompt: | |
| prompt = prompt.replace(placeholder, random.choice(values)) | |
| # For synthetic entries, we'll use a placeholder image | |
| # These will be used only for prompt diversity β we need poster images as targets | |
| # Pair with a random poster image | |
| if entries: | |
| random_poster = random.choice(entries) | |
| entries.append({ | |
| "instruction": f"Generate a YouTube thumbnail: {prompt}", | |
| "output_image": random_poster["output_image"], # reuse poster as target | |
| }) | |
| # Shuffle | |
| random.seed(SEED) | |
| random.shuffle(entries) | |
| # Write JSONL | |
| with open(JSONL_PATH, "w") as f: | |
| for entry in entries: | |
| f.write(json.dumps(entry) + "\n") | |
| logger.info(f"Total training samples: {len(entries)}") | |
| logger.info(f"JSONL saved to: {JSONL_PATH}") | |
| return len(entries) | |
| # βββ Step 2: Train Model ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def train_model(): | |
| """Fine-tune Janus-Pro-7B for thumbnail generation.""" | |
| logger.info("=" * 60) | |
| logger.info("Step 2: Training Janus-Pro-7B") | |
| logger.info("=" * 60) | |
| from transformers import AutoModelForCausalLM, get_cosine_schedule_with_warmup | |
| from janus.models import MultiModalityCausalLM, VLChatProcessor | |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| dtype = torch.bfloat16 | |
| # Initialize tracking | |
| trackio.init(project="thumbnail-vlm", name="janus-pro-thumbnail") | |
| # Load model | |
| logger.info(f"Loading {MODEL_PATH}...") | |
| processor = VLChatProcessor.from_pretrained(MODEL_PATH) | |
| model = AutoModelForCausalLM.from_pretrained( | |
| MODEL_PATH, trust_remote_code=True, torch_dtype=dtype | |
| ) | |
| model = model.to(device) | |
| # Freeze encoders (SigLIP + VQ) β only train LLM backbone + gen_head + aligners | |
| for name, param in model.named_parameters(): | |
| if 'vision_model' in name or 'gen_vision_model' in name: | |
| param.requires_grad = False | |
| trainable = sum(p.numel() for p in model.parameters() if p.requires_grad) | |
| total = sum(p.numel() for p in model.parameters()) | |
| logger.info(f"Parameters: {total/1e6:.1f}M total, {trainable/1e6:.1f}M trainable") | |
| model.train() | |
| # Load data | |
| entries = [] | |
| with open(JSONL_PATH) as f: | |
| for line in f: | |
| entries.append(json.loads(line.strip())) | |
| logger.info(f"Training on {len(entries)} samples") | |
| # Optimizer | |
| optimizer = torch.optim.AdamW( | |
| filter(lambda p: p.requires_grad, model.parameters()), | |
| lr=LR, betas=(0.9, 0.95), weight_decay=0.0, | |
| ) | |
| num_steps = (len(entries) * EPOCHS) // (BATCH_SIZE * GRADIENT_ACCUMULATION) | |
| warmup_steps = int(num_steps * 0.03) | |
| scheduler = get_cosine_schedule_with_warmup(optimizer, warmup_steps, num_steps) | |
| logger.info(f"Training config: epochs={EPOCHS}, batch={BATCH_SIZE}x{GRADIENT_ACCUMULATION}={BATCH_SIZE*GRADIENT_ACCUMULATION}") | |
| logger.info(f"Total steps: {num_steps}, warmup: {warmup_steps}") | |
| # Training loop | |
| global_step = 0 | |
| best_loss = float("inf") | |
| running_loss = 0.0 | |
| for epoch in range(EPOCHS): | |
| random.shuffle(entries) | |
| epoch_loss = 0.0 | |
| epoch_steps = 0 | |
| for batch_idx in range(0, len(entries), BATCH_SIZE): | |
| entry = entries[batch_idx] | |
| # Load target image | |
| img_path = os.path.join(IMAGE_DIR, entry["output_image"]) | |
| if not os.path.exists(img_path): | |
| continue | |
| try: | |
| target_img = Image.open(img_path).convert("RGB") | |
| # Ensure 384x384 | |
| target_img = target_img.resize((IMAGE_SIZE, IMAGE_SIZE), Image.LANCZOS) | |
| # Convert to tensor [-1, 1] | |
| arr = np.array(target_img).astype(np.float32) / 255.0 * 2.0 - 1.0 | |
| target_tensor = torch.from_numpy(arr).permute(2, 0, 1).unsqueeze(0).to(device, dtype=dtype) | |
| # Encode target β VQ tokens | |
| with torch.no_grad(): | |
| quant, emb_loss, info = model.gen_vision_model.encode(target_tensor) | |
| target_tokens = info[2].detach().reshape(1, -1) # [1, 576] | |
| # Build prompt | |
| instruction = entry["instruction"] | |
| if random.random() < CFG_MASK_PROB: | |
| instruction = "" # CFG training: mask 10% of prompts | |
| conversation = [ | |
| {"role": "<|User|>", "content": instruction}, | |
| {"role": "<|Assistant|>", "content": ""}, | |
| ] | |
| sft_format = processor.apply_sft_template_for_multi_turn_prompts( | |
| conversations=conversation, | |
| sft_format=processor.sft_format, | |
| system_prompt="", | |
| ) | |
| prompt = sft_format + processor.image_start_tag | |
| # Tokenize | |
| input_ids = processor.tokenizer.encode(prompt) | |
| input_ids = torch.LongTensor(input_ids).unsqueeze(0).to(device) | |
| # Get embeddings | |
| text_embeds = model.language_model.get_input_embeddings()(input_ids) | |
| img_embeds = model.prepare_gen_img_embeds(target_tokens.reshape(-1)) | |
| img_embeds = img_embeds.reshape(1, IMAGE_TOKEN_NUM, -1) | |
| # Teacher forcing: [text | img[:-1]] β predict img | |
| full_embeds = torch.cat([text_embeds, img_embeds[:, :-1, :]], dim=1) | |
| with torch.amp.autocast('cuda', dtype=dtype): | |
| outputs = model.language_model.model(inputs_embeds=full_embeds) | |
| hidden = outputs.last_hidden_state | |
| # Logits for image token positions | |
| text_len = text_embeds.shape[1] | |
| image_hidden = hidden[:, text_len - 1:, :] # [1, 576, 4096] | |
| logits = model.gen_head(image_hidden) # [1, 576, 16384] | |
| loss = F.cross_entropy( | |
| logits.reshape(-1, VQ_CODEBOOK_SIZE), | |
| target_tokens.reshape(-1), | |
| ) | |
| loss = loss / GRADIENT_ACCUMULATION | |
| loss.backward() | |
| running_loss += loss.item() * GRADIENT_ACCUMULATION | |
| epoch_loss += loss.item() * GRADIENT_ACCUMULATION | |
| epoch_steps += 1 | |
| # Optimizer step | |
| if (batch_idx // BATCH_SIZE + 1) % GRADIENT_ACCUMULATION == 0: | |
| torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) | |
| optimizer.step() | |
| scheduler.step() | |
| optimizer.zero_grad() | |
| global_step += 1 | |
| # Logging | |
| if global_step % LOG_EVERY == 0: | |
| avg_loss = running_loss / LOG_EVERY | |
| current_lr = scheduler.get_last_lr()[0] | |
| print(f"step={global_step}/{num_steps}, epoch={epoch+1}/{EPOCHS}, " | |
| f"loss={avg_loss:.4f}, lr={current_lr:.2e}") | |
| trackio.log({ | |
| "train/loss": avg_loss, | |
| "train/lr": current_lr, | |
| "train/epoch": epoch + 1, | |
| "train/step": global_step, | |
| }) | |
| running_loss = 0.0 | |
| # Save checkpoint | |
| if global_step % SAVE_EVERY == 0: | |
| ckpt_dir = os.path.join(OUTPUT_DIR, f"checkpoint-{global_step}") | |
| os.makedirs(ckpt_dir, exist_ok=True) | |
| model.save_pretrained(ckpt_dir) | |
| processor.save_pretrained(ckpt_dir) | |
| logger.info(f"Checkpoint saved: {ckpt_dir}") | |
| if epoch_loss / max(epoch_steps, 1) < best_loss: | |
| best_loss = epoch_loss / epoch_steps | |
| best_dir = os.path.join(OUTPUT_DIR, "best") | |
| os.makedirs(best_dir, exist_ok=True) | |
| model.save_pretrained(best_dir) | |
| processor.save_pretrained(best_dir) | |
| logger.info(f"New best model! loss={best_loss:.4f}") | |
| except Exception as e: | |
| logger.warning(f"Error at batch {batch_idx}: {e}") | |
| optimizer.zero_grad() | |
| continue | |
| avg_epoch_loss = epoch_loss / max(epoch_steps, 1) | |
| logger.info(f"Epoch {epoch+1}/{EPOCHS} complete. Avg loss: {avg_epoch_loss:.4f}") | |
| # Final save | |
| final_dir = os.path.join(OUTPUT_DIR, "final") | |
| os.makedirs(final_dir, exist_ok=True) | |
| model.save_pretrained(final_dir) | |
| processor.save_pretrained(final_dir) | |
| # Push to Hub | |
| logger.info(f"Pushing model to {HUB_MODEL_ID}...") | |
| try: | |
| model.push_to_hub(HUB_MODEL_ID, token=os.environ.get("HF_TOKEN")) | |
| processor.push_to_hub(HUB_MODEL_ID, token=os.environ.get("HF_TOKEN")) | |
| logger.info(f"Model pushed to: https://huggingface.co/{HUB_MODEL_ID}") | |
| except Exception as e: | |
| logger.error(f"Failed to push to hub: {e}") | |
| trackio.finish() | |
| return model, processor | |
| # βββ Step 3: Evaluate ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def evaluate_model(model=None, processor=None): | |
| """Generate sample thumbnails to verify the model works.""" | |
| logger.info("=" * 60) | |
| logger.info("Step 3: Evaluating Model") | |
| logger.info("=" * 60) | |
| if model is None: | |
| from transformers import AutoModelForCausalLM | |
| from janus.models import MultiModalityCausalLM, VLChatProcessor | |
| model_path = os.path.join(OUTPUT_DIR, "final") | |
| if not os.path.exists(model_path): | |
| model_path = MODEL_PATH | |
| processor = VLChatProcessor.from_pretrained(model_path) | |
| model = AutoModelForCausalLM.from_pretrained( | |
| model_path, trust_remote_code=True, torch_dtype=torch.bfloat16 | |
| ).cuda().eval() | |
| else: | |
| model.eval() | |
| device = next(model.parameters()).device | |
| eval_dir = os.path.join(OUTPUT_DIR, "eval_samples") | |
| os.makedirs(eval_dir, exist_ok=True) | |
| test_prompts = [ | |
| "A professional tech review YouTube thumbnail showing a sleek smartphone with dramatic lighting and bold text 'BEST PHONE 2025'", | |
| "Cooking video thumbnail with a delicious pasta dish, steam rising, warm kitchen lighting, text 'EASY 15-MIN RECIPE'", | |
| "Gaming thumbnail with an epic battle scene, neon glow effects, excited gamer face, text 'INSANE GAMEPLAY'", | |
| "Travel vlog thumbnail showing a stunning sunset over Santorini, Greece, golden hour, text 'DREAM VACATION'", | |
| "Fitness transformation thumbnail with before/after split, motivational energy, text '30 DAY CHALLENGE'", | |
| ] | |
| for i, prompt in enumerate(test_prompts): | |
| logger.info(f"Generating thumbnail {i+1}/{len(test_prompts)}: {prompt[:50]}...") | |
| conversation = [ | |
| {"role": "<|User|>", "content": prompt}, | |
| {"role": "<|Assistant|>", "content": ""}, | |
| ] | |
| sft_format = processor.apply_sft_template_for_multi_turn_prompts( | |
| conversations=conversation, | |
| sft_format=processor.sft_format, | |
| system_prompt="", | |
| ) | |
| prompt_text = sft_format + processor.image_start_tag | |
| with torch.inference_mode(): | |
| input_ids = processor.tokenizer.encode(prompt_text) | |
| input_ids = torch.LongTensor(input_ids) | |
| tokens = torch.zeros((2, len(input_ids)), dtype=torch.int).to(device) | |
| tokens[0, :] = input_ids # conditional | |
| tokens[1, :] = input_ids # unconditional | |
| tokens[1, 1:-1] = processor.pad_id | |
| inputs_embeds = model.language_model.get_input_embeddings()(tokens) | |
| generated = torch.zeros((1, IMAGE_TOKEN_NUM), dtype=torch.int).to(device) | |
| past_kv = None | |
| for t in range(IMAGE_TOKEN_NUM): | |
| outputs = model.language_model.model( | |
| inputs_embeds=inputs_embeds, use_cache=True, past_key_values=past_kv | |
| ) | |
| past_kv = outputs.past_key_values | |
| logits = model.gen_head(outputs.last_hidden_state[:, -1, :]) | |
| logit_cond = logits[0:1] | |
| logit_uncond = logits[1:2] | |
| logits_guided = logit_uncond + 5.0 * (logit_cond - logit_uncond) | |
| probs = torch.softmax(logits_guided, dim=-1) | |
| next_tok = torch.multinomial(probs, num_samples=1) | |
| generated[:, t] = next_tok.squeeze(-1) | |
| next_tok_exp = torch.cat([next_tok, next_tok], dim=0) | |
| img_emb = model.prepare_gen_img_embeds(next_tok_exp.squeeze(-1)) | |
| inputs_embeds = img_emb.unsqueeze(1) | |
| dec = model.gen_vision_model.decode_code( | |
| generated, shape=[1, 8, IMAGE_SIZE // 16, IMAGE_SIZE // 16] | |
| ) | |
| dec = dec.to(torch.float32).cpu().numpy().transpose(0, 2, 3, 1) | |
| dec = np.clip((dec + 1) / 2 * 255, 0, 255).astype(np.uint8) | |
| img = Image.fromarray(dec[0]) | |
| save_path = os.path.join(eval_dir, f"thumbnail_{i}.png") | |
| img.save(save_path) | |
| logger.info(f" Saved: {save_path}") | |
| logger.info(f"Evaluation complete! Samples saved to {eval_dir}/") | |
| # βββ Main ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if __name__ == "__main__": | |
| random.seed(SEED) | |
| np.random.seed(SEED) | |
| torch.manual_seed(SEED) | |
| # Step 1: Prepare data | |
| num_samples = prepare_training_data(max_poster_samples=8000, max_synthetic=2000) | |
| # Step 2: Train | |
| model, processor = train_model() | |
| # Step 3: Evaluate | |
| evaluate_model(model, processor) | |
| logger.info("=" * 60) | |
| logger.info("PIPELINE COMPLETE!") | |
| logger.info(f" Model: https://huggingface.co/{HUB_MODEL_ID}") | |
| logger.info(f" Eval samples: {OUTPUT_DIR}/eval_samples/") | |
| logger.info("=" * 60) | |