planpalette / app.py
Omar Ahmed
Use non-gated DreamShaper image model
eb2128f
Raw
History Blame Contribute Delete
22.2 kB
from __future__ import annotations
import html
import os
from dataclasses import dataclass
from functools import lru_cache
from typing import Iterable
import cv2
import gradio as gr
import numpy as np
from PIL import Image, ImageFilter
try:
import spaces
except ImportError:
spaces = None
APP_TITLE = "PlanPalette"
APP_SUBTITLE = "Generate a furnished top-down architectural floor plan render from a reference palette and CAD plan."
BASE_MODEL_ID = os.getenv("PLANPALETTE_BASE_MODEL", "Lykon/dreamshaper-xl-lightning")
IS_HF_SPACE = bool(os.getenv("SPACE_ID"))
@dataclass
class PaletteColor:
rgb: tuple[int, int, int]
percent: float
material: str
def pil_to_rgb_array(image: Image.Image) -> np.ndarray:
return np.asarray(image.convert("RGB"), dtype=np.uint8)
def rgb_to_hex(rgb: Iterable[int]) -> str:
r, g, b = [int(v) for v in rgb]
return f"#{r:02X}{g:02X}{b:02X}"
def infer_material_name(rgb: tuple[int, int, int]) -> str:
color = np.uint8([[list(rgb)]])
hsv = cv2.cvtColor(color, cv2.COLOR_RGB2HSV)[0, 0]
hue, sat, val = int(hsv[0]), int(hsv[1]), int(hsv[2])
if val < 70:
return "charcoal line / deep accent"
if sat < 35 and val > 205:
return "plaster / light stone"
if sat < 45:
return "concrete / neutral finish"
if 18 <= hue <= 38:
return "wood / warm flooring"
if 39 <= hue <= 82:
return "planting / landscape"
if 83 <= hue <= 104:
return "mint glass / cool surface"
if 105 <= hue <= 135:
return "water / blue finish"
if 136 <= hue <= 165:
return "soft fabric / feature zone"
return "accent material"
def sample_reference_pixels(image: np.ndarray, max_samples: int = 26000) -> np.ndarray:
pixels = image.reshape(-1, 3)
gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY).reshape(-1)
hsv = cv2.cvtColor(image, cv2.COLOR_RGB2HSV).reshape(-1, 3)
# Keep meaningful color and neutral finish pixels, but avoid pure paper and
# black linework so the palette reflects the reference styling.
not_white = gray < 244
not_black_line = gray > 35
has_visual_weight = (hsv[:, 1] > 18) | (gray < 225)
candidates = pixels[not_white & not_black_line & has_visual_weight]
if len(candidates) < 64:
candidates = pixels[(gray > 25) & (gray < 248)]
if len(candidates) == 0:
candidates = pixels
if len(candidates) > max_samples:
rng = np.random.default_rng(42)
candidates = candidates[rng.choice(len(candidates), max_samples, replace=False)]
return candidates.astype(np.float32)
def extract_palette(image: np.ndarray, k: int = 6) -> list[PaletteColor]:
samples = sample_reference_pixels(image)
k = int(max(2, min(k, len(samples), 8)))
criteria = (
cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER,
35,
0.8,
)
compactness, labels, centers = cv2.kmeans(
samples,
k,
None,
criteria,
3,
cv2.KMEANS_PP_CENTERS,
)
del compactness
counts = np.bincount(labels.flatten(), minlength=k).astype(np.float32)
order = np.argsort(counts)[::-1]
palette: list[PaletteColor] = []
total = float(counts.sum()) or 1.0
for idx in order:
rgb = tuple(np.clip(np.rint(centers[idx]), 0, 255).astype(int).tolist())
palette.append(
PaletteColor(
rgb=rgb,
percent=float(counts[idx] / total),
material=infer_material_name(rgb),
)
)
return palette
def make_line_mask(cad_rgb: np.ndarray) -> np.ndarray:
gray = cv2.cvtColor(cad_rgb, cv2.COLOR_RGB2GRAY)
gray = cv2.GaussianBlur(gray, (3, 3), 0)
_, otsu = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
adaptive = cv2.adaptiveThreshold(
gray,
255,
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY_INV,
31,
9,
)
canny = cv2.Canny(gray, 60, 170)
line_mask = cv2.bitwise_or(otsu, adaptive)
line_mask = cv2.bitwise_or(line_mask, canny)
line_mask = cv2.morphologyEx(line_mask, cv2.MORPH_CLOSE, np.ones((2, 2), np.uint8), iterations=1)
# Keep text, thin walls, and hatch marks visible while preventing tiny specks
# from driving segmentation.
line_mask = cv2.dilate(line_mask, np.ones((2, 2), np.uint8), iterations=1)
return line_mask > 0
def resize_for_sdxl(image: Image.Image, max_side: int = 1024, min_side: int = 512) -> Image.Image:
width, height = image.size
scale = max(min_side / min(width, height), 1.0)
if max(width, height) * scale > max_side:
scale = max_side / max(width, height)
new_width = max(8, int(width * scale))
new_height = max(8, int(height * scale))
new_width = int(round(new_width / 8) * 8)
new_height = int(round(new_height / 8) * 8)
return image.convert("RGB").resize((new_width, new_height), Image.Resampling.LANCZOS)
def make_canny_control_image(cad_image: Image.Image) -> Image.Image:
cad_rgb = pil_to_rgb_array(cad_image)
gray = cv2.cvtColor(cad_rgb, cv2.COLOR_RGB2GRAY)
gray = cv2.GaussianBlur(gray, (3, 3), 0)
edges = cv2.Canny(gray, 70, 180)
edges = cv2.dilate(edges, np.ones((2, 2), np.uint8), iterations=1)
control = np.stack([edges, edges, edges], axis=-1)
return Image.fromarray(control, mode="RGB")
def make_palette_style_canvas(size: tuple[int, int], palette: list[PaletteColor]) -> Image.Image:
width, height = size
palette_rgbs = [item.rgb for item in palette[:6]] or [
(232, 221, 199),
(204, 222, 214),
(215, 224, 235),
(224, 208, 212),
]
rng = np.random.default_rng(42)
low_w = max(16, width // 48)
low_h = max(16, height // 48)
palette_array = np.array(palette_rgbs, dtype=np.float32)
weights = np.array([max(item.percent, 0.04) for item in palette[: len(palette_rgbs)]], dtype=np.float32)
if len(weights) != len(palette_rgbs):
weights = np.ones(len(palette_rgbs), dtype=np.float32)
weights = weights / weights.sum()
color_indices = rng.choice(len(palette_rgbs), size=(low_h, low_w), p=weights)
canvas = palette_array[color_indices]
canvas = cv2.resize(canvas, (width, height), interpolation=cv2.INTER_CUBIC)
canvas = cv2.GaussianBlur(canvas, (0, 0), 18)
white = np.full_like(canvas, 255)
canvas = canvas * 0.68 + white * 0.32
paper_noise = rng.normal(0, 3.5, size=canvas.shape).astype(np.float32)
canvas = np.clip(canvas + paper_noise, 0, 255).astype(np.uint8)
return Image.fromarray(canvas, mode="RGB").filter(ImageFilter.GaussianBlur(radius=0.6))
def overlay_original_linework(base_image: Image.Image, cad_image: Image.Image, strength: float) -> Image.Image:
if strength <= 0:
return base_image.convert("RGB")
cad_resized = cad_image.convert("RGB").resize(base_image.size, Image.Resampling.LANCZOS)
cad_rgb = pil_to_rgb_array(cad_resized)
base_rgb = pil_to_rgb_array(base_image).astype(np.float32)
line_mask = make_line_mask(cad_rgb)
line_alpha = cv2.GaussianBlur(line_mask.astype(np.float32), (0, 0), 0.55)[..., None] * float(strength)
line_tone = np.minimum(cad_rgb.astype(np.float32), 55)
composited = base_rgb * (1 - line_alpha) + line_tone * line_alpha
return Image.fromarray(np.clip(composited, 0, 255).astype(np.uint8), mode="RGB")
def palette_prompt_fragment(palette: list[PaletteColor]) -> str:
colors = ", ".join(rgb_to_hex(item.rgb) for item in palette[:6])
materials = ", ".join(item.material for item in palette[:4])
return f"reference palette colors {colors}; material mood: {materials}"
def describe_plan_canvas(cad_image: Image.Image) -> str:
width, height = cad_image.size
aspect = width / max(height, 1)
if aspect > 1.55:
return "wide horizontal multi-unit floor plan composition"
if aspect < 0.8:
return "tall vertical architectural floor plan composition"
return "balanced architectural floor plan composition"
def build_ai_prompt(palette: list[PaletteColor], prompt_hint: str, cad_image: Image.Image) -> str:
user_hint = prompt_hint.strip() if prompt_hint else "top-down furnished real estate floor plan render"
return (
f"{user_hint}, high quality top-down architectural visualization, furnished apartment plan, "
"white walls, wood flooring, marble and tile floors, beds, sofas, dining tables, kitchen counters, "
"bathroom fixtures, plants, balconies, realistic material textures, clean real estate marketing plan, "
"orthographic top view, crisp room boundaries, bright professional render, "
f"{describe_plan_canvas(cad_image)}, "
"render the floor plan as a finished colored marketing image, not as a CAD drawing, "
"avoid black blueprint linework, avoid engineering symbols, avoid title blocks, avoid logos, "
f"{palette_prompt_fragment(palette)}"
)
@lru_cache(maxsize=1)
def load_text_to_image_pipeline():
import torch
from diffusers import AutoPipelineForText2Image
use_cuda = torch.cuda.is_available()
if not use_cuda and IS_HF_SPACE:
raise RuntimeError("AI mode needs GPU or ZeroGPU hardware. Please switch the Hugging Face Space hardware.")
if not use_cuda and os.getenv("PLANPALETTE_ALLOW_CPU", "1") != "1":
raise RuntimeError("No CUDA GPU found. Set PLANPALETTE_ALLOW_CPU=1 to try very slow CPU inference.")
dtype = torch.float16 if use_cuda else torch.float32
pipe = AutoPipelineForText2Image.from_pretrained(
BASE_MODEL_ID,
torch_dtype=dtype,
use_safetensors=True,
)
if use_cuda:
pipe.enable_model_cpu_offload()
else:
pipe.to("cpu")
pipe.enable_attention_slicing()
return pipe
def _ai_colorize_floor_plan(
reference_image: Image.Image,
cad_image: Image.Image,
palette: list[PaletteColor],
prompt_hint: str,
steps: int,
linework_strength: float,
) -> Image.Image:
del reference_image
pipe = load_text_to_image_pipeline()
default_max_side = "1024" if IS_HF_SPACE else "640"
model_cad = resize_for_sdxl(cad_image, max_side=int(os.getenv("PLANPALETTE_MAX_SIDE", default_max_side)))
prompt = build_ai_prompt(palette, prompt_hint, model_cad)
result = pipe(
prompt=prompt,
num_inference_steps=int(steps),
guidance_scale=1.0,
width=model_cad.width,
height=model_cad.height,
).images[0]
return overlay_original_linework(result, model_cad, linework_strength)
if spaces is not None and IS_HF_SPACE:
ai_colorize_floor_plan = spaces.GPU(duration=60)(_ai_colorize_floor_plan)
else:
ai_colorize_floor_plan = _ai_colorize_floor_plan
def connected_region_map(line_mask: np.ndarray) -> tuple[np.ndarray, int]:
height, width = line_mask.shape
gap_closed_lines = cv2.dilate(line_mask.astype(np.uint8) * 255, np.ones((5, 5), np.uint8), iterations=1)
fillable = cv2.bitwise_not(gap_closed_lines)
fillable = cv2.morphologyEx(fillable, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8), iterations=1)
num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(fillable, connectivity=8)
min_area = max(220, int(height * width * 0.004))
region_map = np.zeros((height, width), dtype=np.int32)
region_id = 1
image_area = height * width
for label in range(1, num_labels):
area = int(stats[label, cv2.CC_STAT_AREA])
if area < min_area or area > int(image_area * 0.92):
continue
component = labels == label
component = cv2.morphologyEx(component.astype(np.uint8), cv2.MORPH_CLOSE, np.ones((9, 9), np.uint8), iterations=1)
component = component.astype(bool) & ~line_mask
if int(component.sum()) < min_area:
continue
region_map[component] = region_id
region_id += 1
if region_id <= 2:
region_map, region_id = fallback_grid_regions(line_mask)
return region_map, region_id - 1
def fallback_grid_regions(line_mask: np.ndarray) -> tuple[np.ndarray, int]:
height, width = line_mask.shape
region_map = np.zeros((height, width), dtype=np.int32)
fillable = ~line_mask
region_id = 1
rows, cols = 4, 4
min_area = max(120, int(height * width * 0.002))
for row in range(rows):
for col in range(cols):
y0 = int(row * height / rows)
y1 = int((row + 1) * height / rows)
x0 = int(col * width / cols)
x1 = int((col + 1) * width / cols)
tile = fillable[y0:y1, x0:x1]
if int(tile.sum()) < min_area:
continue
region_map[y0:y1, x0:x1][tile] = region_id
region_id += 1
return region_map, region_id
def soften_palette_color(rgb: tuple[int, int, int], index: int) -> np.ndarray:
color = np.array(rgb, dtype=np.float32)
white = np.array([255, 255, 255], dtype=np.float32)
softened = color * 0.54 + white * 0.46
# Slight alternating warmth/coolness keeps adjacent rooms readable even when
# the source palette has several near-neutrals.
offsets = np.array(
[
[10, 5, -2],
[-4, 5, 10],
[6, -2, 5],
[-2, 9, -3],
[8, 2, 8],
[-5, 4, 4],
],
dtype=np.float32,
)
return np.clip(softened + offsets[index % len(offsets)], 0, 255)
def colorize_regions(cad_rgb: np.ndarray, line_mask: np.ndarray, region_map: np.ndarray, palette: list[PaletteColor]) -> np.ndarray:
height, width = line_mask.shape
fill_layer = np.full((height, width, 3), 255, dtype=np.float32)
palette_rgbs = [item.rgb for item in palette] or [(218, 205, 184), (188, 210, 198), (201, 213, 228)]
region_ids = [idx for idx in np.unique(region_map) if idx > 0]
for assignment_index, region_id in enumerate(region_ids):
mask = region_map == region_id
ys, xs = np.where(mask)
if len(xs) == 0:
continue
centroid_bias = int((xs.mean() / max(width, 1)) * 2 + (ys.mean() / max(height, 1)) * 3)
palette_index = (assignment_index + centroid_bias) % len(palette_rgbs)
base_color = soften_palette_color(palette_rgbs[palette_index], assignment_index)
fill_layer[mask] = base_color
region_alpha = (region_map > 0).astype(np.float32)
region_alpha = cv2.GaussianBlur(region_alpha, (0, 0), 1.35)
region_alpha = np.clip(region_alpha[..., None] * 0.78, 0, 0.78)
cad_float = cad_rgb.astype(np.float32)
brightened_cad = cad_float * 0.45 + 255 * 0.55
colorized = brightened_cad * (1 - region_alpha) + fill_layer * region_alpha
subtle_shadow = cv2.GaussianBlur(line_mask.astype(np.float32), (0, 0), 2.2)[..., None]
colorized = colorized * (1 - subtle_shadow * 0.08)
line_alpha = cv2.GaussianBlur(line_mask.astype(np.float32), (0, 0), 0.45)[..., None]
original_line_tone = np.minimum(cad_float, 35)
composited = colorized * (1 - line_alpha) + original_line_tone * line_alpha
return np.clip(composited, 0, 255).astype(np.uint8)
def build_legend_html(palette: list[PaletteColor], region_count: int | None = None) -> str:
if not palette:
return "<div class='legend-empty'>Upload a reference image to extract a palette.</div>"
swatches = []
for item in palette:
hex_color = rgb_to_hex(item.rgb)
label = html.escape(item.material.title())
swatches.append(
f"""
<div class="swatch-row">
<span class="swatch" style="background:{hex_color};"></span>
<div class="swatch-copy">
<strong>{hex_color}</strong>
<span>{label} - {item.percent * 100:.1f}%</span>
</div>
</div>
"""
)
return f"""
<section class="legend-panel">
<div class="legend-stat">
<strong>{len(palette)}</strong>
<span>reference colors guiding the image model</span>
</div>
<div class="legend-list">
{''.join(swatches)}
</div>
</section>
"""
def transfer_style(
reference_image: Image.Image | None,
cad_image: Image.Image | None,
palette_size: int,
prompt_hint: str,
steps: int,
linework_strength: float,
) -> tuple[Image.Image | None, str]:
if reference_image is None or cad_image is None:
return None, "<div class='legend-empty'>Upload both floor plans, then run PlanPalette.</div>"
reference_rgb = pil_to_rgb_array(reference_image)
palette = extract_palette(reference_rgb, k=palette_size)
try:
final = ai_colorize_floor_plan(
reference_image,
cad_image,
palette,
prompt_hint,
steps,
linework_strength,
)
except Exception as exc:
escaped = html.escape(str(exc))
return None, f"<div class='legend-empty'>AI generation failed: {escaped}</div>"
return final, build_legend_html(palette)
CUSTOM_CSS = """
:root {
--pp-ink: #171717;
--pp-muted: #5c646f;
--pp-line: #d8dde3;
--pp-surface: #f8f7f4;
--pp-accent: #1f7a6d;
--pp-accent-strong: #145a51;
}
.gradio-container {
max-width: 1180px !important;
margin: 0 auto;
color: var(--pp-ink);
background:
linear-gradient(180deg, rgba(248, 247, 244, 0.98), rgba(246, 248, 249, 0.98));
}
.pp-header {
padding: 18px 0 8px;
border-bottom: 1px solid var(--pp-line);
margin-bottom: 14px;
}
.pp-title {
margin: 0;
font-size: clamp(2rem, 3vw, 3.2rem);
line-height: 1.02;
font-weight: 780;
letter-spacing: 0;
}
.pp-subtitle {
margin: 8px 0 0;
max-width: 760px;
color: var(--pp-muted);
font-size: 1rem;
line-height: 1.5;
}
.pp-panel {
border: 1px solid var(--pp-line) !important;
border-radius: 8px !important;
background: rgba(255, 255, 255, 0.82) !important;
}
.pp-run-button {
min-height: 46px;
border-radius: 6px !important;
background: var(--pp-accent) !important;
border-color: var(--pp-accent) !important;
color: white !important;
font-weight: 700 !important;
}
.pp-run-button:hover {
background: var(--pp-accent-strong) !important;
}
.legend-panel {
border: 1px solid var(--pp-line);
border-radius: 8px;
background: #ffffff;
padding: 14px;
}
.legend-stat {
display: flex;
align-items: baseline;
gap: 10px;
padding-bottom: 12px;
margin-bottom: 12px;
border-bottom: 1px solid var(--pp-line);
}
.legend-stat strong {
font-size: 1.75rem;
line-height: 1;
}
.legend-stat span,
.swatch-copy span,
.legend-empty {
color: var(--pp-muted);
}
.legend-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 10px;
}
.swatch-row {
display: flex;
gap: 10px;
align-items: center;
min-width: 0;
}
.swatch {
width: 36px;
height: 36px;
flex: 0 0 auto;
border-radius: 6px;
border: 1px solid rgba(0, 0, 0, 0.12);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.32);
}
.swatch-copy {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.swatch-copy strong {
font-size: 0.92rem;
}
.swatch-copy span {
font-size: 0.84rem;
line-height: 1.25;
}
.legend-empty {
border: 1px dashed var(--pp-line);
border-radius: 8px;
background: #ffffff;
padding: 16px;
}
"""
with gr.Blocks(title=APP_TITLE, css=CUSTOM_CSS, theme=gr.themes.Soft(primary_hue="teal", neutral_hue="slate")) as demo:
gr.HTML(
f"""
<header class="pp-header">
<h1 class="pp-title">{APP_TITLE}</h1>
<p class="pp-subtitle">{APP_SUBTITLE}</p>
</header>
"""
)
with gr.Row(equal_height=True):
with gr.Column(scale=1, elem_classes=["pp-panel"]):
reference_input = gr.Image(
label="Reference Styled Floor Plan",
type="pil",
image_mode="RGB",
height=360,
)
with gr.Column(scale=1, elem_classes=["pp-panel"]):
cad_input = gr.Image(
label="Raw CAD Floor Plan",
type="pil",
image_mode="RGB",
height=360,
)
with gr.Row():
palette_size = gr.Slider(
minimum=3,
maximum=8,
value=6,
step=1,
label="Palette Size",
info="Number of dominant reference colors to transfer.",
)
steps = gr.Slider(
minimum=2,
maximum=8,
value=4,
step=1,
label="AI Steps",
info="Lightning/turbo models work best at low step counts.",
)
linework_strength = gr.Slider(
minimum=0,
maximum=0.6,
value=0,
step=0.02,
label="CAD Line Overlay",
info="Set to 0 for pure AI render.",
)
with gr.Row():
prompt_hint = gr.Textbox(
label="Style Hint",
value="top-down furnished real estate floor plan render like an architectural marketing brochure",
lines=2,
)
run_button = gr.Button("Generate Colorized Plan", variant="primary", elem_classes=["pp-run-button"])
with gr.Row(equal_height=True):
with gr.Column(scale=1):
output_image = gr.Image(
label="Final PNG",
type="pil",
image_mode="RGB",
format="png",
height=460,
)
with gr.Column(scale=1):
legend_output = gr.HTML(
value="<div class='legend-empty'>Upload both floor plans, then run PlanPalette.</div>",
label="Palette / Material Legend",
)
run_button.click(
fn=transfer_style,
inputs=[reference_input, cad_input, palette_size, prompt_hint, steps, linework_strength],
outputs=[output_image, legend_output],
)
if __name__ == "__main__":
demo.launch(server_name="0.0.0.0", server_port=7860)