File size: 5,283 Bytes
b09c64f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import base64
import io
from typing import Any, Dict

import torch
from PIL import Image
from diffusers import (
    StableDiffusionXLPipeline,
    StableDiffusionXLImg2ImgPipeline,
    DPMSolverMultistepScheduler,
)

DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
DTYPE = torch.float16 if DEVICE == "cuda" else torch.float32


def _decode_image(b64: str) -> Image.Image:
    """Decode a base64 string (optionally a data: URL) into a PIL RGB image."""
    if b64.strip().startswith("data:") and "," in b64:
        b64 = b64.split(",", 1)[1]
    raw = base64.b64decode(b64)
    return Image.open(io.BytesIO(raw)).convert("RGB")


def _encode_image(img: Image.Image) -> str:
    """Encode a PIL image as a base64 PNG string."""
    buf = io.BytesIO()
    img.save(buf, format="PNG")
    return base64.b64encode(buf.getvalue()).decode("utf-8")


class EndpointHandler:
    """
    Dual-mode SDXL handler for LUSTIFY-v2.0.

    Request shape (HF Inference Endpoints):
        {
          "inputs": "<prompt>",
          "parameters": {
            "negative_prompt": "...",        # optional
            "num_inference_steps": 30,        # optional
            "guidance_scale": 5.0,            # optional (author recommends 4-7)
            "width": 1024, "height": 1024,    # txt2img only
            "seed": 12345,                    # optional, for reproducibility
            "image": "<base64>",              # PRESENCE switches to img2img
            "strength": 0.6                   # img2img only (0-1)
          }
        }

    Response: {"image": "<base64 png>", "mode": "txt2img"|"img2img", "parameters": {...}}
    """

    def __init__(self, path: str = ""):
        # Base text-to-image pipeline. add_watermarker=False avoids the optional
        # invisible-watermark dependency.
        self.txt2img = StableDiffusionXLPipeline.from_pretrained(
            path,
            torch_dtype=DTYPE,
            use_safetensors=True,
            add_watermarker=False,
        )
        # DPM++ 2M SDE Karras — the checkpoint author's recommended sampler.
        self.txt2img.scheduler = DPMSolverMultistepScheduler.from_config(
            self.txt2img.scheduler.config,
            algorithm_type="sde-dpmsolver++",
            use_karras_sigmas=True,
        )
        self.txt2img.to(DEVICE)

        # img2img reuses the exact same weights/components — no extra VRAM cost.
        self.img2img = StableDiffusionXLImg2ImgPipeline(**self.txt2img.components)
        self.img2img.to(DEVICE)

        if DEVICE == "cuda":
            self.txt2img.enable_vae_slicing()
            try:
                self.txt2img.enable_xformers_memory_efficient_attention()
                self.img2img.enable_xformers_memory_efficient_attention()
            except Exception:
                # xformers is optional; the pipelines run fine without it.
                pass

    def __call__(self, data: Dict[str, Any]) -> Dict[str, Any]:
        prompt = data.get("inputs") or data.get("prompt")
        params = data.get("parameters") or {}

        if not prompt:
            return {"error": "No prompt provided. Send {'inputs': '<prompt>'}."}

        negative_prompt = params.get("negative_prompt")
        num_inference_steps = int(params.get("num_inference_steps", 30))
        guidance_scale = float(params.get("guidance_scale", 5.0))
        width = int(params.get("width", 1024))
        height = int(params.get("height", 1024))

        seed = params.get("seed")
        generator = None
        if seed is not None:
            generator = torch.Generator(device=DEVICE).manual_seed(int(seed))

        init_b64 = params.get("image")
        strength = float(params.get("strength", 0.6))

        try:
            if init_b64:
                init_image = _decode_image(init_b64)
                result = self.img2img(
                    prompt=prompt,
                    negative_prompt=negative_prompt,
                    image=init_image,
                    strength=strength,
                    num_inference_steps=num_inference_steps,
                    guidance_scale=guidance_scale,
                    generator=generator,
                )
                mode = "img2img"
            else:
                result = self.txt2img(
                    prompt=prompt,
                    negative_prompt=negative_prompt,
                    width=width,
                    height=height,
                    num_inference_steps=num_inference_steps,
                    guidance_scale=guidance_scale,
                    generator=generator,
                )
                mode = "txt2img"
        except Exception as e:
            return {
                "error": f"{type(e).__name__}: {e}",
                "mode": "img2img" if init_b64 else "txt2img",
            }

        image = result.images[0]
        return {
            "image": _encode_image(image),
            "mode": mode,
            "parameters": {
                "num_inference_steps": num_inference_steps,
                "guidance_scale": guidance_scale,
                "strength": strength if init_b64 else None,
                "width": width,
                "height": height,
                "seed": int(seed) if seed is not None else None,
            },
        }