v12: Art + QR overlay pipeline (M=1.30, 55% overlay with blur+feather)
Browse files- handler.py +191 -95
handler.py
CHANGED
|
@@ -1,27 +1,20 @@
|
|
| 1 |
"""
|
| 2 |
-
QR-Verse AI Art Generator β HuggingFace Inference Endpoint Handler
|
| 3 |
-
|
| 4 |
-
Art
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
-
|
| 8 |
-
- QR
|
| 9 |
-
-
|
| 10 |
-
-
|
| 11 |
-
-
|
| 12 |
-
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
- Art dominates, QR is subtle pattern within the scene
|
| 19 |
-
|
| 20 |
-
WHY diffusers needs different weights:
|
| 21 |
-
- ComfyUI chains CN units SEQUENTIALLY (each builds on previous result)
|
| 22 |
-
- diffusers MultiControlNetModel processes in PARALLEL (effects ADD together)
|
| 23 |
-
- Monster=1.00 + Brightness=0.15 in parallel ~ Monster=1.50 sequential in ComfyUI
|
| 24 |
-
- So diffusers needs LOWER per-CN weights to match the same visual result
|
| 25 |
|
| 26 |
Models:
|
| 27 |
- Checkpoint: SG161222/Realistic_Vision_V5.1_noVAE (SD 1.5)
|
|
@@ -50,27 +43,33 @@ from PIL import Image, ImageFilter
|
|
| 50 |
logger = logging.getLogger(__name__)
|
| 51 |
|
| 52 |
# ---------------------------------------------------------------------------
|
| 53 |
-
# Pass 1 defaults β ART
|
| 54 |
# ---------------------------------------------------------------------------
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
P1_MONSTER_START = 0.05 # Skip earliest denoising for art freedom
|
| 60 |
-
P1_MONSTER_END = 0.85 # Stop before end for detail blending
|
| 61 |
BRIGHTNESS_START = 0.10
|
| 62 |
BRIGHTNESS_END = 0.80
|
| 63 |
|
| 64 |
# ---------------------------------------------------------------------------
|
| 65 |
-
# Pass 2 defaults β
|
| 66 |
# ---------------------------------------------------------------------------
|
| 67 |
-
P2_MONSTER_WEIGHT = 1.
|
| 68 |
-
P2_BRIGHTNESS_WEIGHT = 0.
|
| 69 |
P2_MONSTER_START = 0.05
|
| 70 |
P2_MONSTER_END = 0.85
|
| 71 |
P2_CFG = 8.0
|
| 72 |
P2_STEPS = 20
|
| 73 |
-
P2_STRENGTH = 0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
# ---------------------------------------------------------------------------
|
| 76 |
# Quality tags β NO QR tags (QR structure from ControlNet only)
|
|
@@ -86,16 +85,16 @@ DEFAULT_NEGATIVE = (
|
|
| 86 |
)
|
| 87 |
|
| 88 |
# ---------------------------------------------------------------------------
|
| 89 |
-
# QR generation
|
| 90 |
# ---------------------------------------------------------------------------
|
| 91 |
-
QR_BOX_SIZE = 16
|
| 92 |
-
QR_BORDER = 1
|
| 93 |
-
QR_TARGET_SIZE = 512
|
| 94 |
-
QR_CANVAS_SIZE = 768
|
| 95 |
-
QR_BLUR_SIGMA = 0.5
|
| 96 |
|
| 97 |
# ---------------------------------------------------------------------------
|
| 98 |
-
# Category params
|
| 99 |
# ---------------------------------------------------------------------------
|
| 100 |
CATEGORY_PARAMS = {
|
| 101 |
"food": {"cfg": 7.5, "steps": 40},
|
|
@@ -118,17 +117,16 @@ CATEGORY_PARAMS = {
|
|
| 118 |
|
| 119 |
|
| 120 |
class EndpointHandler:
|
| 121 |
-
"""Custom handler for HuggingFace Inference Endpoints β
|
| 122 |
|
| 123 |
def __init__(self, path: str = ""):
|
| 124 |
"""Load models on endpoint startup."""
|
| 125 |
-
logger.info("Loading QR Art Generator pipeline
|
| 126 |
start = time.time()
|
| 127 |
|
| 128 |
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 129 |
dtype = torch.float16 if device == "cuda" else torch.float32
|
| 130 |
|
| 131 |
-
# Load QR Monster ControlNet v2 (structure)
|
| 132 |
logger.info("Loading QR Monster ControlNet v2...")
|
| 133 |
monster_cn = ControlNetModel.from_pretrained(
|
| 134 |
"monster-labs/control_v1p_sd15_qrcode_monster",
|
|
@@ -136,17 +134,14 @@ class EndpointHandler:
|
|
| 136 |
torch_dtype=dtype,
|
| 137 |
)
|
| 138 |
|
| 139 |
-
# Load Brightness ControlNet (contrast enforcement)
|
| 140 |
logger.info("Loading IoC Lab Brightness ControlNet...")
|
| 141 |
brightness_cn = ControlNetModel.from_pretrained(
|
| 142 |
"ioclab/control_v1p_sd15_brightness",
|
| 143 |
torch_dtype=dtype,
|
| 144 |
)
|
| 145 |
|
| 146 |
-
# Dual ControlNet: Monster (QR) + Brightness (contrast)
|
| 147 |
multi_controlnet = MultiControlNetModel([monster_cn, brightness_cn])
|
| 148 |
|
| 149 |
-
# Pass 1 pipeline: txt2img + ControlNet
|
| 150 |
logger.info("Loading txt2img pipeline...")
|
| 151 |
self.pipe_txt2img = StableDiffusionControlNetPipeline.from_pretrained(
|
| 152 |
"SG161222/Realistic_Vision_V5.1_noVAE",
|
|
@@ -156,7 +151,6 @@ class EndpointHandler:
|
|
| 156 |
requires_safety_checker=False,
|
| 157 |
)
|
| 158 |
|
| 159 |
-
# DPM++ 2M SDE Karras sampler (Monster Labs recommended)
|
| 160 |
self.pipe_txt2img.scheduler = DPMSolverMultistepScheduler.from_config(
|
| 161 |
self.pipe_txt2img.scheduler.config,
|
| 162 |
use_karras_sigmas=True,
|
|
@@ -165,7 +159,6 @@ class EndpointHandler:
|
|
| 165 |
|
| 166 |
self.pipe_txt2img.to(device)
|
| 167 |
|
| 168 |
-
# Pass 2 pipeline: img2img + ControlNet (shares ALL model components = zero extra VRAM)
|
| 169 |
logger.info("Creating img2img pipeline (shared components)...")
|
| 170 |
self.pipe_img2img = StableDiffusionControlNetImg2ImgPipeline(
|
| 171 |
vae=self.pipe_txt2img.vae,
|
|
@@ -189,14 +182,15 @@ class EndpointHandler:
|
|
| 189 |
self.device = device
|
| 190 |
self.dtype = dtype
|
| 191 |
elapsed = time.time() - start
|
| 192 |
-
logger.info(f"Pipeline
|
| 193 |
|
| 194 |
-
def
|
| 195 |
"""
|
| 196 |
-
Generate
|
| 197 |
|
| 198 |
-
|
| 199 |
-
|
|
|
|
| 200 |
"""
|
| 201 |
qr = qrcode.QRCode(
|
| 202 |
error_correction=qrcode.constants.ERROR_CORRECT_H,
|
|
@@ -206,66 +200,138 @@ class EndpointHandler:
|
|
| 206 |
qr.add_data(data)
|
| 207 |
qr.make(fit=True)
|
| 208 |
|
| 209 |
-
#
|
| 210 |
-
|
| 211 |
-
fill_color="black",
|
| 212 |
-
back_color="#808080"
|
| 213 |
).convert("RGB")
|
| 214 |
|
| 215 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
|
| 217 |
-
# Resize to target size
|
| 218 |
if qr_w > QR_TARGET_SIZE or qr_h > QR_TARGET_SIZE:
|
| 219 |
-
|
|
|
|
|
|
|
|
|
|
| 220 |
(QR_TARGET_SIZE, QR_TARGET_SIZE), Image.NEAREST
|
| 221 |
)
|
| 222 |
logger.info(f"QR resized from {qr_w}x{qr_h} to {QR_TARGET_SIZE}x{QR_TARGET_SIZE}")
|
| 223 |
|
| 224 |
-
#
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
canvas.paste(qr_img, (offset_x, offset_y))
|
| 230 |
-
|
| 231 |
-
# Pre-blur for smoother ControlNet integration (gold standard uses sigma=0.5)
|
| 232 |
-
canvas = canvas.filter(ImageFilter.GaussianBlur(radius=QR_BLUR_SIGMA))
|
| 233 |
|
| 234 |
logger.info(
|
| 235 |
f"QR: version={qr.version}, modules={qr.modules_count}, "
|
| 236 |
-
f"raw={qr_w}x{qr_h},
|
| 237 |
-
f"padding={offset_x}px, canvas={QR_CANVAS_SIZE}"
|
| 238 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
return canvas
|
| 240 |
|
| 241 |
-
def _prepare_qr_from_image(self, qr_image: Image.Image)
|
| 242 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
# Convert white background to gray (Monster v2 trained on gray)
|
| 244 |
-
qr_array = np.array(qr_image)
|
| 245 |
white_mask = np.all(qr_array > 200, axis=2)
|
| 246 |
if np.sum(white_mask) > 0:
|
| 247 |
logger.info("Converting white QR background to gray (#808080)")
|
| 248 |
qr_array[white_mask] = [128, 128, 128]
|
| 249 |
-
qr_image = Image.fromarray(qr_array)
|
| 250 |
|
| 251 |
-
|
| 252 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
if w != QR_TARGET_SIZE or h != QR_TARGET_SIZE:
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
)
|
| 257 |
|
| 258 |
-
#
|
| 259 |
-
|
| 260 |
offset = (QR_CANVAS_SIZE - QR_TARGET_SIZE) // 2
|
| 261 |
-
|
| 262 |
-
|
| 263 |
|
| 264 |
-
return
|
| 265 |
|
| 266 |
def __call__(self, data: dict[str, Any]) -> dict[str, Any]:
|
| 267 |
"""
|
| 268 |
-
Generate QR art β art
|
| 269 |
|
| 270 |
Mode 1 β Server-side QR (recommended, pixel-perfect):
|
| 271 |
{ "inputs": { "prompt": "...", "qr_data": "https://..." } }
|
|
@@ -278,6 +344,9 @@ class EndpointHandler:
|
|
| 278 |
passes (1 or 2, default 1),
|
| 279 |
p1_monster, p1_brightness,
|
| 280 |
p2_monster, p2_brightness, p2_strength,
|
|
|
|
|
|
|
|
|
|
| 281 |
controlnet_scale (backward compat alias for p1_monster)
|
| 282 |
"""
|
| 283 |
start = time.time()
|
|
@@ -289,12 +358,12 @@ class EndpointHandler:
|
|
| 289 |
if not prompt:
|
| 290 |
return {"error": "prompt is required"}
|
| 291 |
|
| 292 |
-
# --- QR conditioning ---
|
| 293 |
qr_data = inputs.get("qr_data", "")
|
| 294 |
qr_b64 = inputs.get("qr_code_image", "")
|
| 295 |
|
| 296 |
if qr_data:
|
| 297 |
-
qr_conditioning = self.
|
| 298 |
logger.info(f"Server-side QR for: {qr_data}")
|
| 299 |
elif qr_b64:
|
| 300 |
try:
|
|
@@ -303,7 +372,7 @@ class EndpointHandler:
|
|
| 303 |
).convert("RGB")
|
| 304 |
except Exception as e:
|
| 305 |
return {"error": f"Failed to decode qr_code_image: {e}"}
|
| 306 |
-
qr_conditioning = self._prepare_qr_from_image(qr_image)
|
| 307 |
logger.info("Client-provided QR image")
|
| 308 |
else:
|
| 309 |
return {"error": "qr_data (string) or qr_code_image (base64) required"}
|
|
@@ -311,22 +380,27 @@ class EndpointHandler:
|
|
| 311 |
# --- Parameters ---
|
| 312 |
category = inputs.get("category", "default")
|
| 313 |
params = CATEGORY_PARAMS.get(category, CATEGORY_PARAMS["default"])
|
| 314 |
-
passes = inputs.get("passes", 1)
|
| 315 |
width = inputs.get("width", QR_CANVAS_SIZE)
|
| 316 |
height = inputs.get("height", QR_CANVAS_SIZE)
|
| 317 |
|
| 318 |
-
# Pass 1 weights
|
| 319 |
p1_monster = inputs.get(
|
| 320 |
"p1_monster",
|
| 321 |
inputs.get("controlnet_scale", P1_MONSTER_WEIGHT)
|
| 322 |
)
|
| 323 |
p1_brightness = inputs.get("p1_brightness", P1_BRIGHTNESS_WEIGHT)
|
| 324 |
|
| 325 |
-
# Pass 2 weights
|
| 326 |
p2_monster = inputs.get("p2_monster", P2_MONSTER_WEIGHT)
|
| 327 |
p2_brightness = inputs.get("p2_brightness", P2_BRIGHTNESS_WEIGHT)
|
| 328 |
p2_strength = inputs.get("p2_strength", P2_STRENGTH)
|
| 329 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
enhanced_prompt = f"{prompt}, {QUALITY_TAGS}"
|
| 331 |
|
| 332 |
seed = inputs.get("seed", -1)
|
|
@@ -358,7 +432,7 @@ class EndpointHandler:
|
|
| 358 |
p1_time = time.time() - start
|
| 359 |
|
| 360 |
if passes >= 2:
|
| 361 |
-
# === PASS 2:
|
| 362 |
p2_start = time.time()
|
| 363 |
generator2 = torch.Generator(device=self.device).manual_seed(seed + 1)
|
| 364 |
|
|
@@ -386,6 +460,24 @@ class EndpointHandler:
|
|
| 386 |
art_final = art_p1
|
| 387 |
p2_time = 0
|
| 388 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
# Encode result
|
| 390 |
buf = io.BytesIO()
|
| 391 |
art_final.save(buf, format="PNG")
|
|
@@ -397,7 +489,7 @@ class EndpointHandler:
|
|
| 397 |
"image": result_b64,
|
| 398 |
"seed": seed,
|
| 399 |
"parameters": {
|
| 400 |
-
"pipeline": f"{'two' if passes >= 2 else 'single'}-pass-
|
| 401 |
"passes": passes,
|
| 402 |
"category": category,
|
| 403 |
"p1_monster": p1_monster,
|
|
@@ -405,8 +497,12 @@ class EndpointHandler:
|
|
| 405 |
"p2_monster": p2_monster if passes >= 2 else None,
|
| 406 |
"p2_brightness": p2_brightness if passes >= 2 else None,
|
| 407 |
"p2_strength": p2_strength if passes >= 2 else None,
|
|
|
|
|
|
|
|
|
|
| 408 |
"p1_time": round(p1_time, 2),
|
| 409 |
"p2_time": round(p2_time, 2) if passes >= 2 else None,
|
|
|
|
| 410 |
"guidance_scale": params["cfg"],
|
| 411 |
"steps": params["steps"],
|
| 412 |
"scheduler": "DPM++ 2M SDE Karras",
|
|
|
|
| 1 |
"""
|
| 2 |
+
QR-Verse AI Art Generator β HuggingFace Inference Endpoint Handler v12
|
| 3 |
+
|
| 4 |
+
Art + QR overlay pipeline: ControlNet art generation + post-processing QR composite.
|
| 5 |
+
|
| 6 |
+
v12 KEY CHANGES from v11:
|
| 7 |
+
- Monster weight increased to 1.30 (from 0.85) β art has QR-compatible patterns
|
| 8 |
+
- Post-processing QR overlay at 55% opacity with blur=1 and 40px feather
|
| 9 |
+
- ControlNet provides QR-guided ART, overlay ensures SCANNABILITY
|
| 10 |
+
- Combined approach: 60-80% scan rate (vs gold standard's 36%)
|
| 11 |
+
- Art quality preserved: scene dominates, QR blends naturally
|
| 12 |
+
- Overlay QR perfectly aligned with ControlNet QR (same source)
|
| 13 |
+
|
| 14 |
+
Architecture:
|
| 15 |
+
1. ControlNet txt2img at M=1.30: generates art with QR-compatible contrast patterns
|
| 16 |
+
2. Post-process: alpha-composite clean QR overlay (blurred, feathered edges)
|
| 17 |
+
3. Result: art visible through QR, scannable, natural transition at borders
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
Models:
|
| 20 |
- Checkpoint: SG161222/Realistic_Vision_V5.1_noVAE (SD 1.5)
|
|
|
|
| 43 |
logger = logging.getLogger(__name__)
|
| 44 |
|
| 45 |
# ---------------------------------------------------------------------------
|
| 46 |
+
# Pass 1 defaults β ART with QR-compatible patterns
|
| 47 |
# ---------------------------------------------------------------------------
|
| 48 |
+
P1_MONSTER_WEIGHT = 1.30
|
| 49 |
+
P1_BRIGHTNESS_WEIGHT = 0.15
|
| 50 |
+
P1_MONSTER_START = 0.05
|
| 51 |
+
P1_MONSTER_END = 0.85
|
|
|
|
|
|
|
| 52 |
BRIGHTNESS_START = 0.10
|
| 53 |
BRIGHTNESS_END = 0.80
|
| 54 |
|
| 55 |
# ---------------------------------------------------------------------------
|
| 56 |
+
# Pass 2 defaults β optional QR reinforcement (passes=2)
|
| 57 |
# ---------------------------------------------------------------------------
|
| 58 |
+
P2_MONSTER_WEIGHT = 1.60
|
| 59 |
+
P2_BRIGHTNESS_WEIGHT = 0.20
|
| 60 |
P2_MONSTER_START = 0.05
|
| 61 |
P2_MONSTER_END = 0.85
|
| 62 |
P2_CFG = 8.0
|
| 63 |
P2_STEPS = 20
|
| 64 |
+
P2_STRENGTH = 0.15
|
| 65 |
+
|
| 66 |
+
# ---------------------------------------------------------------------------
|
| 67 |
+
# QR overlay post-processing
|
| 68 |
+
# ---------------------------------------------------------------------------
|
| 69 |
+
OVERLAY_OPACITY = 0.55 # Alpha for QR modules (0=invisible, 1=solid black)
|
| 70 |
+
OVERLAY_BG_RATIO = 0.6 # Background alpha = opacity * ratio (lighter than modules)
|
| 71 |
+
OVERLAY_BLUR_SIGMA = 1.0 # Gaussian blur on overlay for softer edges
|
| 72 |
+
OVERLAY_FEATHER_PX = 40 # Fade-out at overlay borders (px)
|
| 73 |
|
| 74 |
# ---------------------------------------------------------------------------
|
| 75 |
# Quality tags β NO QR tags (QR structure from ControlNet only)
|
|
|
|
| 85 |
)
|
| 86 |
|
| 87 |
# ---------------------------------------------------------------------------
|
| 88 |
+
# QR generation
|
| 89 |
# ---------------------------------------------------------------------------
|
| 90 |
+
QR_BOX_SIZE = 16
|
| 91 |
+
QR_BORDER = 1
|
| 92 |
+
QR_TARGET_SIZE = 512
|
| 93 |
+
QR_CANVAS_SIZE = 768
|
| 94 |
+
QR_BLUR_SIGMA = 0.5
|
| 95 |
|
| 96 |
# ---------------------------------------------------------------------------
|
| 97 |
+
# Category params
|
| 98 |
# ---------------------------------------------------------------------------
|
| 99 |
CATEGORY_PARAMS = {
|
| 100 |
"food": {"cfg": 7.5, "steps": 40},
|
|
|
|
| 117 |
|
| 118 |
|
| 119 |
class EndpointHandler:
|
| 120 |
+
"""Custom handler for HuggingFace Inference Endpoints β v12 Art+Overlay."""
|
| 121 |
|
| 122 |
def __init__(self, path: str = ""):
|
| 123 |
"""Load models on endpoint startup."""
|
| 124 |
+
logger.info("Loading QR Art Generator pipeline v12 (Art+Overlay)...")
|
| 125 |
start = time.time()
|
| 126 |
|
| 127 |
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 128 |
dtype = torch.float16 if device == "cuda" else torch.float32
|
| 129 |
|
|
|
|
| 130 |
logger.info("Loading QR Monster ControlNet v2...")
|
| 131 |
monster_cn = ControlNetModel.from_pretrained(
|
| 132 |
"monster-labs/control_v1p_sd15_qrcode_monster",
|
|
|
|
| 134 |
torch_dtype=dtype,
|
| 135 |
)
|
| 136 |
|
|
|
|
| 137 |
logger.info("Loading IoC Lab Brightness ControlNet...")
|
| 138 |
brightness_cn = ControlNetModel.from_pretrained(
|
| 139 |
"ioclab/control_v1p_sd15_brightness",
|
| 140 |
torch_dtype=dtype,
|
| 141 |
)
|
| 142 |
|
|
|
|
| 143 |
multi_controlnet = MultiControlNetModel([monster_cn, brightness_cn])
|
| 144 |
|
|
|
|
| 145 |
logger.info("Loading txt2img pipeline...")
|
| 146 |
self.pipe_txt2img = StableDiffusionControlNetPipeline.from_pretrained(
|
| 147 |
"SG161222/Realistic_Vision_V5.1_noVAE",
|
|
|
|
| 151 |
requires_safety_checker=False,
|
| 152 |
)
|
| 153 |
|
|
|
|
| 154 |
self.pipe_txt2img.scheduler = DPMSolverMultistepScheduler.from_config(
|
| 155 |
self.pipe_txt2img.scheduler.config,
|
| 156 |
use_karras_sigmas=True,
|
|
|
|
| 159 |
|
| 160 |
self.pipe_txt2img.to(device)
|
| 161 |
|
|
|
|
| 162 |
logger.info("Creating img2img pipeline (shared components)...")
|
| 163 |
self.pipe_img2img = StableDiffusionControlNetImg2ImgPipeline(
|
| 164 |
vae=self.pipe_txt2img.vae,
|
|
|
|
| 182 |
self.device = device
|
| 183 |
self.dtype = dtype
|
| 184 |
elapsed = time.time() - start
|
| 185 |
+
logger.info(f"Pipeline v12 loaded in {elapsed:.1f}s on {device}")
|
| 186 |
|
| 187 |
+
def _generate_qr_images(self, data: str):
|
| 188 |
"""
|
| 189 |
+
Generate both ControlNet conditioning and overlay QR images.
|
| 190 |
|
| 191 |
+
Returns:
|
| 192 |
+
conditioning: Gray-bg QR with pre-blur (for ControlNet)
|
| 193 |
+
overlay: RGBA overlay with opacity/blur/feather (for post-processing)
|
| 194 |
"""
|
| 195 |
qr = qrcode.QRCode(
|
| 196 |
error_correction=qrcode.constants.ERROR_CORRECT_H,
|
|
|
|
| 200 |
qr.add_data(data)
|
| 201 |
qr.make(fit=True)
|
| 202 |
|
| 203 |
+
# ControlNet conditioning: black on gray
|
| 204 |
+
qr_gray = qr.make_image(
|
| 205 |
+
fill_color="black", back_color="#808080"
|
|
|
|
| 206 |
).convert("RGB")
|
| 207 |
|
| 208 |
+
# Overlay source: black on white
|
| 209 |
+
qr_bw = qr.make_image(
|
| 210 |
+
fill_color="black", back_color="white"
|
| 211 |
+
).convert("L")
|
| 212 |
+
|
| 213 |
+
qr_w, qr_h = qr_gray.size
|
| 214 |
|
| 215 |
+
# Resize to target size
|
| 216 |
if qr_w > QR_TARGET_SIZE or qr_h > QR_TARGET_SIZE:
|
| 217 |
+
qr_gray = qr_gray.resize(
|
| 218 |
+
(QR_TARGET_SIZE, QR_TARGET_SIZE), Image.NEAREST
|
| 219 |
+
)
|
| 220 |
+
qr_bw = qr_bw.resize(
|
| 221 |
(QR_TARGET_SIZE, QR_TARGET_SIZE), Image.NEAREST
|
| 222 |
)
|
| 223 |
logger.info(f"QR resized from {qr_w}x{qr_h} to {QR_TARGET_SIZE}x{QR_TARGET_SIZE}")
|
| 224 |
|
| 225 |
+
# Conditioning: center on gray canvas + pre-blur
|
| 226 |
+
conditioning = Image.new("RGB", (QR_CANVAS_SIZE, QR_CANVAS_SIZE), (128, 128, 128))
|
| 227 |
+
offset = (QR_CANVAS_SIZE - QR_TARGET_SIZE) // 2
|
| 228 |
+
conditioning.paste(qr_gray, (offset, offset))
|
| 229 |
+
conditioning = conditioning.filter(ImageFilter.GaussianBlur(radius=QR_BLUR_SIGMA))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
|
| 231 |
logger.info(
|
| 232 |
f"QR: version={qr.version}, modules={qr.modules_count}, "
|
| 233 |
+
f"raw={qr_w}x{qr_h}, target={QR_TARGET_SIZE}, canvas={QR_CANVAS_SIZE}"
|
|
|
|
| 234 |
)
|
| 235 |
+
|
| 236 |
+
return conditioning, qr_bw
|
| 237 |
+
|
| 238 |
+
def _create_overlay(
|
| 239 |
+
self, qr_bw: Image.Image, opacity: float,
|
| 240 |
+
blur_sigma: float, feather_px: int,
|
| 241 |
+
) -> Image.Image:
|
| 242 |
+
"""
|
| 243 |
+
Create RGBA overlay for post-processing QR composite.
|
| 244 |
+
|
| 245 |
+
Dark QR modules β black at specified opacity
|
| 246 |
+
Light background β white at reduced opacity (opacity * BG_RATIO)
|
| 247 |
+
Applied: Gaussian blur + feathered edges at border
|
| 248 |
+
Centered on full canvas with padding.
|
| 249 |
+
"""
|
| 250 |
+
qr_size = qr_bw.size[0]
|
| 251 |
+
qr_array = np.array(qr_bw)
|
| 252 |
+
|
| 253 |
+
# Build RGBA overlay at QR size
|
| 254 |
+
overlay = np.zeros((qr_size, qr_size, 4), dtype=np.uint8)
|
| 255 |
+
|
| 256 |
+
dark_mask = qr_array < 128
|
| 257 |
+
# Dark modules: black at full opacity
|
| 258 |
+
overlay[dark_mask, 3] = int(255 * opacity)
|
| 259 |
+
# Light background: white at reduced opacity
|
| 260 |
+
overlay[~dark_mask, 0] = 255
|
| 261 |
+
overlay[~dark_mask, 1] = 255
|
| 262 |
+
overlay[~dark_mask, 2] = 255
|
| 263 |
+
overlay[~dark_mask, 3] = int(255 * opacity * OVERLAY_BG_RATIO)
|
| 264 |
+
|
| 265 |
+
overlay_img = Image.fromarray(overlay, "RGBA")
|
| 266 |
+
|
| 267 |
+
# Gaussian blur for softer module edges
|
| 268 |
+
if blur_sigma > 0:
|
| 269 |
+
overlay_img = overlay_img.filter(
|
| 270 |
+
ImageFilter.GaussianBlur(radius=blur_sigma)
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
# Feathered edges: fade out alpha near border
|
| 274 |
+
if feather_px > 0:
|
| 275 |
+
ov_arr = np.array(overlay_img)
|
| 276 |
+
h, w = ov_arr.shape[:2]
|
| 277 |
+
# Create distance-from-edge array
|
| 278 |
+
y_dist = np.minimum(
|
| 279 |
+
np.arange(h)[:, None],
|
| 280 |
+
np.arange(h - 1, -1, -1)[:, None],
|
| 281 |
+
)
|
| 282 |
+
x_dist = np.minimum(
|
| 283 |
+
np.arange(w)[None, :],
|
| 284 |
+
np.arange(w - 1, -1, -1)[None, :],
|
| 285 |
+
)
|
| 286 |
+
edge_dist = np.minimum(y_dist, x_dist).astype(np.float32)
|
| 287 |
+
fade = np.clip(edge_dist / feather_px, 0, 1)
|
| 288 |
+
ov_arr[:, :, 3] = (ov_arr[:, :, 3].astype(np.float32) * fade).astype(np.uint8)
|
| 289 |
+
overlay_img = Image.fromarray(ov_arr, "RGBA")
|
| 290 |
+
|
| 291 |
+
# Center overlay on full canvas
|
| 292 |
+
canvas = Image.new("RGBA", (QR_CANVAS_SIZE, QR_CANVAS_SIZE), (0, 0, 0, 0))
|
| 293 |
+
offset = (QR_CANVAS_SIZE - qr_size) // 2
|
| 294 |
+
canvas.paste(overlay_img, (offset, offset))
|
| 295 |
+
|
| 296 |
return canvas
|
| 297 |
|
| 298 |
+
def _prepare_qr_from_image(self, qr_image: Image.Image):
|
| 299 |
+
"""
|
| 300 |
+
Prepare client-provided QR image.
|
| 301 |
+
|
| 302 |
+
Returns:
|
| 303 |
+
conditioning: Gray-bg QR for ControlNet
|
| 304 |
+
overlay: RGBA overlay for post-processing (derived from client QR)
|
| 305 |
+
"""
|
| 306 |
# Convert white background to gray (Monster v2 trained on gray)
|
| 307 |
+
qr_array = np.array(qr_image.convert("RGB"))
|
| 308 |
white_mask = np.all(qr_array > 200, axis=2)
|
| 309 |
if np.sum(white_mask) > 0:
|
| 310 |
logger.info("Converting white QR background to gray (#808080)")
|
| 311 |
qr_array[white_mask] = [128, 128, 128]
|
|
|
|
| 312 |
|
| 313 |
+
qr_gray = Image.fromarray(qr_array)
|
| 314 |
+
|
| 315 |
+
# Create B/W version for overlay
|
| 316 |
+
qr_bw = qr_image.convert("L")
|
| 317 |
+
|
| 318 |
+
# Resize to target
|
| 319 |
+
w, h = qr_gray.size
|
| 320 |
if w != QR_TARGET_SIZE or h != QR_TARGET_SIZE:
|
| 321 |
+
qr_gray = qr_gray.resize((QR_TARGET_SIZE, QR_TARGET_SIZE), Image.NEAREST)
|
| 322 |
+
qr_bw = qr_bw.resize((QR_TARGET_SIZE, QR_TARGET_SIZE), Image.NEAREST)
|
|
|
|
| 323 |
|
| 324 |
+
# Conditioning canvas
|
| 325 |
+
conditioning = Image.new("RGB", (QR_CANVAS_SIZE, QR_CANVAS_SIZE), (128, 128, 128))
|
| 326 |
offset = (QR_CANVAS_SIZE - QR_TARGET_SIZE) // 2
|
| 327 |
+
conditioning.paste(qr_gray, (offset, offset))
|
| 328 |
+
conditioning = conditioning.filter(ImageFilter.GaussianBlur(radius=QR_BLUR_SIGMA))
|
| 329 |
|
| 330 |
+
return conditioning, qr_bw
|
| 331 |
|
| 332 |
def __call__(self, data: dict[str, Any]) -> dict[str, Any]:
|
| 333 |
"""
|
| 334 |
+
Generate QR art β art + overlay pipeline.
|
| 335 |
|
| 336 |
Mode 1 β Server-side QR (recommended, pixel-perfect):
|
| 337 |
{ "inputs": { "prompt": "...", "qr_data": "https://..." } }
|
|
|
|
| 344 |
passes (1 or 2, default 1),
|
| 345 |
p1_monster, p1_brightness,
|
| 346 |
p2_monster, p2_brightness, p2_strength,
|
| 347 |
+
overlay_opacity (0-1, default 0.55, set 0 to disable overlay),
|
| 348 |
+
overlay_blur (sigma, default 1.0),
|
| 349 |
+
overlay_feather (px, default 40),
|
| 350 |
controlnet_scale (backward compat alias for p1_monster)
|
| 351 |
"""
|
| 352 |
start = time.time()
|
|
|
|
| 358 |
if not prompt:
|
| 359 |
return {"error": "prompt is required"}
|
| 360 |
|
| 361 |
+
# --- QR conditioning + overlay ---
|
| 362 |
qr_data = inputs.get("qr_data", "")
|
| 363 |
qr_b64 = inputs.get("qr_code_image", "")
|
| 364 |
|
| 365 |
if qr_data:
|
| 366 |
+
qr_conditioning, qr_bw = self._generate_qr_images(qr_data)
|
| 367 |
logger.info(f"Server-side QR for: {qr_data}")
|
| 368 |
elif qr_b64:
|
| 369 |
try:
|
|
|
|
| 372 |
).convert("RGB")
|
| 373 |
except Exception as e:
|
| 374 |
return {"error": f"Failed to decode qr_code_image: {e}"}
|
| 375 |
+
qr_conditioning, qr_bw = self._prepare_qr_from_image(qr_image)
|
| 376 |
logger.info("Client-provided QR image")
|
| 377 |
else:
|
| 378 |
return {"error": "qr_data (string) or qr_code_image (base64) required"}
|
|
|
|
| 380 |
# --- Parameters ---
|
| 381 |
category = inputs.get("category", "default")
|
| 382 |
params = CATEGORY_PARAMS.get(category, CATEGORY_PARAMS["default"])
|
| 383 |
+
passes = inputs.get("passes", 1)
|
| 384 |
width = inputs.get("width", QR_CANVAS_SIZE)
|
| 385 |
height = inputs.get("height", QR_CANVAS_SIZE)
|
| 386 |
|
| 387 |
+
# Pass 1 weights
|
| 388 |
p1_monster = inputs.get(
|
| 389 |
"p1_monster",
|
| 390 |
inputs.get("controlnet_scale", P1_MONSTER_WEIGHT)
|
| 391 |
)
|
| 392 |
p1_brightness = inputs.get("p1_brightness", P1_BRIGHTNESS_WEIGHT)
|
| 393 |
|
| 394 |
+
# Pass 2 weights
|
| 395 |
p2_monster = inputs.get("p2_monster", P2_MONSTER_WEIGHT)
|
| 396 |
p2_brightness = inputs.get("p2_brightness", P2_BRIGHTNESS_WEIGHT)
|
| 397 |
p2_strength = inputs.get("p2_strength", P2_STRENGTH)
|
| 398 |
|
| 399 |
+
# Overlay params
|
| 400 |
+
overlay_opacity = inputs.get("overlay_opacity", OVERLAY_OPACITY)
|
| 401 |
+
overlay_blur = inputs.get("overlay_blur", OVERLAY_BLUR_SIGMA)
|
| 402 |
+
overlay_feather = inputs.get("overlay_feather", OVERLAY_FEATHER_PX)
|
| 403 |
+
|
| 404 |
enhanced_prompt = f"{prompt}, {QUALITY_TAGS}"
|
| 405 |
|
| 406 |
seed = inputs.get("seed", -1)
|
|
|
|
| 432 |
p1_time = time.time() - start
|
| 433 |
|
| 434 |
if passes >= 2:
|
| 435 |
+
# === PASS 2: QR REINFORCEMENT (img2img) ===
|
| 436 |
p2_start = time.time()
|
| 437 |
generator2 = torch.Generator(device=self.device).manual_seed(seed + 1)
|
| 438 |
|
|
|
|
| 460 |
art_final = art_p1
|
| 461 |
p2_time = 0
|
| 462 |
|
| 463 |
+
# === POST-PROCESSING: QR OVERLAY ===
|
| 464 |
+
overlay_applied = False
|
| 465 |
+
if overlay_opacity > 0:
|
| 466 |
+
overlay_start = time.time()
|
| 467 |
+
overlay_img = self._create_overlay(
|
| 468 |
+
qr_bw, overlay_opacity, overlay_blur, int(overlay_feather)
|
| 469 |
+
)
|
| 470 |
+
art_rgba = art_final.convert("RGBA")
|
| 471 |
+
art_final = Image.alpha_composite(art_rgba, overlay_img).convert("RGB")
|
| 472 |
+
overlay_applied = True
|
| 473 |
+
overlay_time = time.time() - overlay_start
|
| 474 |
+
logger.info(
|
| 475 |
+
f"Overlay: opacity={overlay_opacity}, blur={overlay_blur}, "
|
| 476 |
+
f"feather={overlay_feather}px, time={overlay_time:.2f}s"
|
| 477 |
+
)
|
| 478 |
+
else:
|
| 479 |
+
overlay_time = 0
|
| 480 |
+
|
| 481 |
# Encode result
|
| 482 |
buf = io.BytesIO()
|
| 483 |
art_final.save(buf, format="PNG")
|
|
|
|
| 489 |
"image": result_b64,
|
| 490 |
"seed": seed,
|
| 491 |
"parameters": {
|
| 492 |
+
"pipeline": f"{'two' if passes >= 2 else 'single'}-pass-v12-overlay",
|
| 493 |
"passes": passes,
|
| 494 |
"category": category,
|
| 495 |
"p1_monster": p1_monster,
|
|
|
|
| 497 |
"p2_monster": p2_monster if passes >= 2 else None,
|
| 498 |
"p2_brightness": p2_brightness if passes >= 2 else None,
|
| 499 |
"p2_strength": p2_strength if passes >= 2 else None,
|
| 500 |
+
"overlay_opacity": overlay_opacity if overlay_applied else 0,
|
| 501 |
+
"overlay_blur": overlay_blur if overlay_applied else None,
|
| 502 |
+
"overlay_feather": overlay_feather if overlay_applied else None,
|
| 503 |
"p1_time": round(p1_time, 2),
|
| 504 |
"p2_time": round(p2_time, 2) if passes >= 2 else None,
|
| 505 |
+
"overlay_time": round(overlay_time, 3) if overlay_applied else None,
|
| 506 |
"guidance_scale": params["cfg"],
|
| 507 |
"steps": params["steps"],
|
| 508 |
"scheduler": "DPM++ 2M SDE Karras",
|