Hadimeeee commited on
Commit
e12aa3c
Β·
verified Β·
1 Parent(s): e8dde12

Upload pipeline.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. pipeline.py +373 -0
pipeline.py ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Mongle Character LoRA β€” Photo-to-Pixel-Art Pipeline
3
+ Standalone script: works after snapshot_download from HuggingFace.
4
+
5
+ Usage:
6
+ from huggingface_hub import snapshot_download
7
+ repo_dir = snapshot_download("Hadimeeee/mongle-character-lora")
8
+ import sys; sys.path.insert(0, repo_dir)
9
+ from pipeline import run_pipeline
10
+ from PIL import Image
11
+
12
+ result = run_pipeline(Image.open("photo.jpg"))
13
+ result["result_nobg"].save("character.png")
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import gc
20
+ import json
21
+ import re
22
+ from pathlib import Path
23
+ from typing import Optional
24
+
25
+ import cv2
26
+ import numpy as np
27
+ import torch
28
+ from PIL import Image
29
+
30
+ REPO_ID = "Hadimeeee/mongle-character-lora"
31
+ LORA_DIR = Path(__file__).parent # same folder as this script
32
+
33
+ # ──────────────────────────────────────────────
34
+ # Image utilities
35
+ # ──────────────────────────────────────────────
36
+
37
+ def make_square(img: Image.Image, size: int = 1024) -> Image.Image:
38
+ img = img.convert("RGB")
39
+ w, h = img.size
40
+ side = max(w, h)
41
+ sq = Image.new("RGB", (side, side), (255, 255, 255))
42
+ sq.paste(img, ((side - w) // 2, (side - h) // 2))
43
+ return sq.resize((size, size), Image.LANCZOS)
44
+
45
+
46
+ def remove_bg(img: Image.Image) -> Image.Image:
47
+ from rembg import remove as rembg_remove
48
+ rgba = rembg_remove(img.convert("RGBA"))
49
+ white = Image.new("RGB", rgba.size, (255, 255, 255))
50
+ white.paste(rgba, mask=rgba.split()[3])
51
+ return white
52
+
53
+
54
+ def remove_bg_rgba(img: Image.Image) -> Image.Image:
55
+ from rembg import remove as rembg_remove
56
+ return rembg_remove(img.convert("RGBA"))
57
+
58
+
59
+ # ──────────────────────────────────────────────
60
+ # SAM β†’ flat color β†’ Canny
61
+ # ──────────────────────────────────────────────
62
+
63
+ def run_sam(img: Image.Image, sam_model: str = "facebook/sam-vit-base"):
64
+ from transformers import SamModel, SamProcessor
65
+ device = "cuda" if torch.cuda.is_available() else "cpu"
66
+ processor = SamProcessor.from_pretrained(sam_model)
67
+ model = SamModel.from_pretrained(sam_model).to(device)
68
+ model.eval()
69
+
70
+ w, h = img.size
71
+ cx, cy = w // 2, h // 2
72
+ inputs = processor(img, input_points=[[[cx, cy]]], return_tensors="pt").to(device)
73
+ with torch.no_grad():
74
+ outputs = model(**inputs)
75
+ masks = processor.post_process_masks(
76
+ outputs.pred_masks.cpu(), inputs["original_sizes"].cpu(),
77
+ inputs["reshaped_input_sizes"].cpu(),
78
+ )[0]
79
+ scores = outputs.iou_scores[0, 0].cpu().numpy()
80
+ mask = masks[0, int(np.argmax(scores))].numpy().astype(np.uint8) * 255
81
+ del model, processor; gc.collect(); torch.cuda.empty_cache()
82
+ return Image.fromarray(mask)
83
+
84
+
85
+ def dominant_color(img: Image.Image, mask: Image.Image):
86
+ arr = np.array(img.convert("RGB"))
87
+ m = np.array(mask) > 128
88
+ px = arr[m]
89
+ if len(px) == 0:
90
+ return (200, 200, 200)
91
+ from sklearn.cluster import KMeans
92
+ k = KMeans(n_clusters=3, n_init=5, random_state=0).fit(px)
93
+ sizes = np.bincount(k.labels_)
94
+ return tuple(int(c) for c in k.cluster_centers_[np.argmax(sizes)])
95
+
96
+
97
+ def build_flat_color(img: Image.Image, mask: Image.Image) -> Image.Image:
98
+ color = dominant_color(img, mask)
99
+ flat = Image.new("RGB", img.size, (255, 255, 255))
100
+ mask_arr = np.array(mask) > 128
101
+ flat_arr = np.array(flat)
102
+ flat_arr[mask_arr] = color
103
+ return Image.fromarray(flat_arr)
104
+
105
+
106
+ def extract_canny(flat: Image.Image, lo: int = 50, hi: int = 150) -> Image.Image:
107
+ gray = cv2.cvtColor(np.array(flat), cv2.COLOR_RGB2GRAY)
108
+ edges = cv2.Canny(gray, lo, hi)
109
+ return Image.fromarray(np.stack([edges] * 3, axis=-1))
110
+
111
+
112
+ # ──────────────────────────────────────────────
113
+ # VLM (Qwen2-VL) β€” appearance extraction
114
+ # ──────────────────────────────────────────────
115
+
116
+ _vlm_model = None
117
+ _vlm_proc = None
118
+
119
+
120
+ def load_vlm(model_name: str = "Qwen/Qwen2-VL-7B-Instruct"):
121
+ global _vlm_model, _vlm_proc
122
+ from transformers import Qwen2VLForConditionalGeneration, AutoProcessor, BitsAndBytesConfig
123
+ bnb = BitsAndBytesConfig(load_in_8bit=True)
124
+ _vlm_model = Qwen2VLForConditionalGeneration.from_pretrained(
125
+ model_name, quantization_config=bnb, device_map="auto"
126
+ )
127
+ _vlm_proc = AutoProcessor.from_pretrained(model_name)
128
+
129
+
130
+ def unload_vlm():
131
+ global _vlm_model, _vlm_proc
132
+ del _vlm_model, _vlm_proc
133
+ _vlm_model = _vlm_proc = None
134
+ gc.collect(); torch.cuda.empty_cache()
135
+
136
+
137
+ def run_vlm(img: Image.Image) -> dict:
138
+ system = (
139
+ "You are a visual analysis assistant. "
140
+ "Analyze the stuffed animal in the image and return ONLY a JSON object "
141
+ "with these fields: animal_type, body_color, secondary_colors (list), "
142
+ "body_shape, eye_style, accessories (list), distinctive_features (list), "
143
+ "controlnet_scale (float 0.45-0.85). "
144
+ "controlnet_scale: 0.45 if no face, 0.5 if pillow-shaped, "
145
+ "0.75 for normal, 0.85 for limbless/round. "
146
+ "No explanation, no markdown, only JSON."
147
+ )
148
+ messages = [{"role": "user", "content": [
149
+ {"type": "image", "image": img},
150
+ {"type": "text", "text": "Analyze this stuffed animal and return JSON."},
151
+ ]}]
152
+ from qwen_vl_utils import process_vision_info
153
+ text = _vlm_proc.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
154
+ image_inputs, _ = process_vision_info(messages)
155
+ inputs = _vlm_proc(text=[text], images=image_inputs, return_tensors="pt")
156
+ inputs = {k: v.to(_vlm_model.device) for k, v in inputs.items()}
157
+ with torch.no_grad():
158
+ out = _vlm_model.generate(**inputs, max_new_tokens=512, temperature=0.1)
159
+ raw = _vlm_proc.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)
160
+ m = re.search(r"\{.*\}", raw, re.DOTALL)
161
+ return json.loads(m.group()) if m else {}
162
+
163
+
164
+ def vlm_json_to_prompt(data: dict, extra_en: str = "") -> tuple[str, float]:
165
+ animal = data.get("animal_type", "plush toy")
166
+ body_col = data.get("body_color", "colorful")
167
+ sec_cols = ", ".join(data.get("secondary_colors", []))
168
+ shape = data.get("body_shape", "round")
169
+ eyes = data.get("eye_style", "round eyes")
170
+ acc = ", ".join(data.get("accessories", []))
171
+ feat = ", ".join(data.get("distinctive_features", []))
172
+ cn_scale = float(data.get("controlnet_scale", 0.75))
173
+
174
+ parts = [
175
+ f"monglestyle, {body_col} {animal} plush",
176
+ shape, eyes,
177
+ ]
178
+ if sec_cols: parts.append(sec_cols)
179
+ if acc: parts.append(acc)
180
+ if feat: parts.append(feat)
181
+ if extra_en: parts.append(extra_en)
182
+ parts += [
183
+ "single stuffed animal toy mascot character, full body, centered",
184
+ "front view, cute chibi proportions, 32-bit pixel art sprite",
185
+ "soft pixel shading, clean silhouette, soft brown outline",
186
+ "pure white background",
187
+ ]
188
+ return ", ".join(p for p in parts if p), cn_scale
189
+
190
+
191
+ # ──────────────────────────────────────────────
192
+ # ControlNet generation
193
+ # ──────────────────────────────────────────────
194
+
195
+ _pipe = None
196
+
197
+
198
+ def load_pipeline(lcm: bool = True):
199
+ global _pipe
200
+ from diffusers import StableDiffusionXLControlNetPipeline, ControlNetModel
201
+ from diffusers.schedulers import LCMScheduler
202
+
203
+ cn = ControlNetModel.from_pretrained(
204
+ "diffusers/controlnet-canny-sdxl-1.0", torch_dtype=torch.float16
205
+ )
206
+ _pipe = StableDiffusionXLControlNetPipeline.from_pretrained(
207
+ "stabilityai/stable-diffusion-xl-base-1.0",
208
+ controlnet=cn, torch_dtype=torch.float16,
209
+ ).to("cuda")
210
+
211
+ if lcm:
212
+ _pipe.load_lora_weights("latent-consistency/lcm-lora-sdxl", adapter_name="lcm")
213
+ _pipe.load_lora_weights(str(LORA_DIR), adapter_name="style")
214
+ _pipe.set_adapters(["lcm", "style"], adapter_weights=[1.0, 0.9])
215
+ _pipe.scheduler = LCMScheduler.from_config(_pipe.scheduler.config)
216
+ else:
217
+ _pipe.load_lora_weights(str(LORA_DIR), adapter_name="style")
218
+ _pipe.set_adapters(["style"], adapter_weights=[0.9])
219
+
220
+
221
+ def unload_pipeline():
222
+ global _pipe
223
+ del _pipe; _pipe = None
224
+ gc.collect(); torch.cuda.empty_cache()
225
+
226
+
227
+ def generate_character(
228
+ canny_img: Image.Image,
229
+ prompt: str,
230
+ cn_scale: float = 0.75,
231
+ steps: int = 8,
232
+ guidance: float = 1.5,
233
+ seed: int = 42,
234
+ lora_scale: float = 0.9,
235
+ ) -> Image.Image:
236
+ neg = "blurry, watermark, text, low quality, deformed, realistic photo, 3d render"
237
+ gen = torch.Generator("cuda").manual_seed(seed)
238
+ out = _pipe(
239
+ prompt=prompt,
240
+ negative_prompt=neg,
241
+ image=canny_img,
242
+ num_inference_steps=steps,
243
+ guidance_scale=guidance,
244
+ controlnet_conditioning_scale=cn_scale,
245
+ cross_attention_kwargs={"scale": lora_scale},
246
+ generator=gen,
247
+ )
248
+ return out.images[0]
249
+
250
+
251
+ # ──────────────────────────────────────────────
252
+ # Main API
253
+ # ──────────────────────────────────────────────
254
+
255
+ def run_pipeline(
256
+ image_pil: Image.Image,
257
+ char_desc_en: str = None,
258
+ lcm: bool = True,
259
+ lora_scale: float = 0.9,
260
+ cn_scale_override: float = None,
261
+ steps: int = 8,
262
+ seed: int = 42,
263
+ out_dir: str = None,
264
+ sam_model: str = "facebook/sam-vit-base",
265
+ vlm_model: str = "Qwen/Qwen2-VL-7B-Instruct",
266
+ ) -> dict:
267
+ """
268
+ Full photo-to-pixel-art pipeline.
269
+
270
+ Args:
271
+ image_pil : Input PIL image (stuffed animal photo)
272
+ char_desc_en : Optional English description to supplement VLM output
273
+ lcm : Use LCM LoRA for fast 8-step generation
274
+ lora_scale : Character LoRA weight (default 0.9)
275
+ cn_scale_override: Override ControlNet scale (None = VLM recommendation)
276
+ steps : Inference steps (8 with LCM, 25-30 without)
277
+ seed : Random seed
278
+ out_dir : Save intermediate outputs here (optional)
279
+ sam_model : SAM model ID
280
+ vlm_model : Qwen2-VL model ID
281
+
282
+ Returns dict with keys:
283
+ result, result_nobg, canny, flat_color, appearance, prompt, cn_scale
284
+ """
285
+ if out_dir:
286
+ Path(out_dir).mkdir(parents=True, exist_ok=True)
287
+
288
+ # STEP 1: Preprocess
289
+ print("[1/5] Preprocessing...")
290
+ sq = make_square(image_pil)
291
+ nobg = remove_bg(sq)
292
+
293
+ # STEP 2: SAM β†’ flat color β†’ Canny
294
+ print("[2/5] SAM + Canny edge extraction...")
295
+ mask = run_sam(nobg, sam_model)
296
+ flat = build_flat_color(nobg, mask)
297
+ canny = extract_canny(flat)
298
+
299
+ # STEP 3: VLM appearance analysis
300
+ print("[3/5] VLM appearance analysis...")
301
+ load_vlm(vlm_model)
302
+ appearance = run_vlm(nobg)
303
+ unload_vlm()
304
+
305
+ prompt, cn_scale = vlm_json_to_prompt(appearance, char_desc_en or "")
306
+ if cn_scale_override is not None:
307
+ cn_scale = cn_scale_override
308
+
309
+ # STEP 4: Generate character
310
+ print("[4/5] Generating pixel art character...")
311
+ guidance = 1.5 if lcm else 7.5
312
+ load_pipeline(lcm=lcm)
313
+ result = generate_character(
314
+ canny, prompt, cn_scale=cn_scale,
315
+ steps=steps, guidance=guidance, seed=seed, lora_scale=lora_scale,
316
+ )
317
+ unload_pipeline()
318
+
319
+ # STEP 5: Remove background from result
320
+ print("[5/5] Final background removal...")
321
+ result_nobg_rgba = remove_bg_rgba(result)
322
+
323
+ # Save outputs
324
+ if out_dir:
325
+ d = Path(out_dir)
326
+ nobg.save(d / "nobg.png")
327
+ flat.save(d / "flat_color.png")
328
+ canny.save(d / "canny.png")
329
+ result.save(d / "result.png")
330
+ result_nobg_rgba.save(d / "result_nobg.png")
331
+ (d / "appearance.json").write_text(json.dumps(appearance, ensure_ascii=False, indent=2))
332
+ (d / "prompt.txt").write_text(prompt)
333
+ print(f"Saved to: {out_dir}")
334
+
335
+ return {
336
+ "result": result,
337
+ "result_nobg": result_nobg_rgba,
338
+ "canny": canny,
339
+ "flat_color": flat,
340
+ "appearance": appearance,
341
+ "prompt": prompt,
342
+ "cn_scale": cn_scale,
343
+ }
344
+
345
+
346
+ # ──────────────────────────────────────────────
347
+ # CLI
348
+ # ──────────────────────────────────────────────
349
+
350
+ if __name__ == "__main__":
351
+ import argparse
352
+ p = argparse.ArgumentParser(description="Mongle character pipeline")
353
+ p.add_argument("--image", required=True, help="Input photo path")
354
+ p.add_argument("--out-dir", default="output", help="Output directory")
355
+ p.add_argument("--desc", default=None, help="English character description (optional)")
356
+ p.add_argument("--no-lcm", dest="lcm", action="store_false", default=True)
357
+ p.add_argument("--cn-scale", type=float, default=None)
358
+ p.add_argument("--steps", type=int, default=8)
359
+ p.add_argument("--seed", type=int, default=42)
360
+ args = p.parse_args()
361
+
362
+ result = run_pipeline(
363
+ image_pil = Image.open(args.image),
364
+ char_desc_en = args.desc,
365
+ lcm = args.lcm,
366
+ cn_scale_override = args.cn_scale,
367
+ steps = args.steps,
368
+ seed = args.seed,
369
+ out_dir = args.out_dir,
370
+ )
371
+ print(f"\nPrompt: {result['prompt']}")
372
+ print(f"cn_scale: {result['cn_scale']}")
373
+ print(f"Done β†’ {args.out_dir}/result_nobg.png")