thangvip's picture
feat: deploy complete Compliment Forest app
9dad6a7 verified
Raw
History Blame
7.77 kB
from __future__ import annotations
import base64
import hashlib
import io
import random
from pathlib import Path
from typing import Protocol
from PIL import Image, ImageDraw, ImageFilter, ImageOps
STYLE_PREFIX = (
"cmprst_forest, soft watercolor storybook illustration, loose wet-on-wet washes, "
"visible cold-press paper grain, no hard outlines, soft feathered edges, "
)
STYLE_SUFFIX = (
", warm dappled light, kind expression, lots of negative space, muted warm palette"
)
NEGATIVE_PROMPT = (
"hard outlines, photorealistic, 3d render, neon, oversaturated, busy background, "
"text, watermark, deformed"
)
class ImageBackend(Protocol):
def generate(self, prompt: str, seed: int) -> str: ...
def compose_flux_prompt(creature_prompt: str) -> str:
return f"{STYLE_PREFIX}{creature_prompt.strip()}{STYLE_SUFFIX}"
def image_to_data_uri(image: Image.Image) -> str:
buffer = io.BytesIO()
image.save(buffer, format="PNG", optimize=True)
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
return f"data:image/png;base64,{encoded}"
class DemoImageBackend:
"""Creates stable local artwork for tests and UI development."""
def __init__(
self,
width: int = 768,
height: int = 576,
asset_dir: str | Path | None = None,
) -> None:
self.width = width
self.height = height
self.asset_dir = (
Path(asset_dir)
if asset_dir is not None
else Path(__file__).resolve().parents[3] / "frontend" / "assets"
)
def _curated_asset(self, prompt: str, seed: int) -> Image.Image | None:
prompt_lower = prompt.casefold()
names = {
"owl": "owl.png",
"snail": "snail.png",
"deer": "deer.png",
"wren": "wren.png",
"fox": "fox-path.png",
}
filename = next(
(asset for keyword, asset in names.items() if keyword in prompt_lower),
"fox-path.png",
)
path = self.asset_dir / filename
if not path.exists():
return None
with Image.open(path) as source:
image = ImageOps.fit(
source.convert("RGB"),
(self.width, self.height),
method=Image.Resampling.LANCZOS,
)
corner = image.getpixel((0, 0))
variation = seed % 3
image.putpixel((0, 0), tuple(min(255, channel + variation) for channel in corner))
return image
def generate(self, prompt: str, seed: int) -> str:
curated = self._curated_asset(prompt, seed)
if curated is not None:
return image_to_data_uri(curated)
prompt_seed = int.from_bytes(
hashlib.sha256(prompt.encode("utf-8")).digest()[:4],
byteorder="big",
)
rng = random.Random(seed ^ prompt_seed)
image = Image.new("RGB", (self.width, self.height), "#f5f0df")
wash = Image.new("RGBA", image.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(wash, "RGBA")
palette = [
(124, 145, 112, 28),
(77, 105, 78, 24),
(208, 151, 139, 22),
(225, 184, 101, 22),
(132, 172, 181, 18),
]
for _ in range(100):
color = rng.choice(palette)
x = rng.randint(-100, self.width)
y = rng.randint(-80, self.height)
radius_x = rng.randint(35, 160)
radius_y = rng.randint(25, 110)
draw.ellipse(
(x - radius_x, y - radius_y, x + radius_x, y + radius_y),
fill=color,
)
wash = wash.filter(ImageFilter.GaussianBlur(radius=22))
image = Image.alpha_composite(image.convert("RGBA"), wash)
creature = Image.new("RGBA", image.size, (0, 0, 0, 0))
creature_draw = ImageDraw.Draw(creature, "RGBA")
center_x = self.width // 2 + rng.randint(-30, 30)
ground_y = int(self.height * 0.72)
body_color = rng.choice(
[(169, 105, 69, 205), (128, 105, 78, 205), (154, 135, 96, 205)]
)
shadow = (76, 91, 67, 35)
creature_draw.ellipse(
(center_x - 145, ground_y - 10, center_x + 145, ground_y + 38),
fill=shadow,
)
creature_draw.ellipse(
(center_x - 96, ground_y - 170, center_x + 90, ground_y + 5),
fill=body_color,
)
creature_draw.ellipse(
(center_x - 72, ground_y - 245, center_x + 74, ground_y - 100),
fill=body_color,
)
creature_draw.polygon(
[
(center_x - 62, ground_y - 210),
(center_x - 88, ground_y - 290),
(center_x - 20, ground_y - 235),
],
fill=body_color,
)
creature_draw.polygon(
[
(center_x + 58, ground_y - 210),
(center_x + 88, ground_y - 290),
(center_x + 20, ground_y - 235),
],
fill=body_color,
)
creature_draw.ellipse(
(center_x - 38, ground_y - 195, center_x - 15, ground_y - 169),
fill=(51, 53, 44, 220),
)
creature_draw.ellipse(
(center_x + 18, ground_y - 195, center_x + 41, ground_y - 169),
fill=(51, 53, 44, 220),
)
creature_draw.ellipse(
(center_x - 6, ground_y - 163, center_x + 9, ground_y - 150),
fill=(72, 57, 47, 190),
)
creature = creature.filter(ImageFilter.GaussianBlur(radius=1.2))
image = Image.alpha_composite(image, creature)
grain = Image.new("RGBA", image.size, (0, 0, 0, 0))
grain_pixels = grain.load()
for y in range(self.height):
for x in range(self.width):
value = rng.randint(0, 10)
grain_pixels[x, y] = (75, 66, 50, value)
image = Image.alpha_composite(image, grain)
return image_to_data_uri(image.convert("RGB"))
class FluxImageBackend:
"""Lazy FLUX.1-dev pipeline that loads a local or Hub LoRA."""
def __init__(
self,
model_id: str = "black-forest-labs/FLUX.1-dev",
lora_id: str = "build-small-hackathon/compliment-forest-flux-lora",
*,
local_files_only: bool = False,
width: int = 768,
height: int = 768,
steps: int = 28,
) -> None:
self.model_id = model_id
self.lora_id = lora_id
self.local_files_only = local_files_only
self.width = width
self.height = height
self.steps = steps
self._pipeline = None
def _load(self):
if self._pipeline is not None:
return self._pipeline
import torch
from diffusers import FluxPipeline
pipeline = FluxPipeline.from_pretrained(
self.model_id,
torch_dtype=torch.bfloat16,
local_files_only=self.local_files_only,
)
pipeline.load_lora_weights(
self.lora_id,
local_files_only=self.local_files_only,
)
pipeline.enable_model_cpu_offload()
self._pipeline = pipeline
return pipeline
def generate(self, prompt: str, seed: int) -> str:
import torch
pipeline = self._load()
generator = torch.Generator(device="cpu").manual_seed(seed)
image = pipeline(
prompt=compose_flux_prompt(prompt),
negative_prompt=NEGATIVE_PROMPT,
width=self.width,
height=self.height,
num_inference_steps=self.steps,
guidance_scale=3.5,
generator=generator,
).images[0]
return image_to_data_uri(image)