import io import json import os import tempfile import zipfile from dataclasses import dataclass from pathlib import Path import cairosvg import cv2 import gradio as gr import numpy as np from PIL import Image MODEL_AVAILABLE = False MODEL_ERROR = None processor = None seg_pipeline = None try: from transformers import pipeline MODEL_AVAILABLE = True except Exception as e: MODEL_ERROR = str(e) @dataclass class PlanConfig: min_room_area_ratio: float = 0.02 wall_thickness: int = 6 variation: str = "balanced" max_rooms: int = 6 use_vision_model: bool = False def pil_to_bgr(img: Image.Image) -> np.ndarray: arr = np.array(img.convert("RGB")) return cv2.cvtColor(arr, cv2.COLOR_RGB2BGR) def bgr_to_pil(arr: np.ndarray) -> Image.Image: rgb = cv2.cvtColor(arr, cv2.COLOR_BGR2RGB) return Image.fromarray(rgb) def load_any_image(image_file): if image_file is None: raise gr.Error("Upload a PNG, JPG, or SVG file first.") path = image_file if isinstance(image_file, str) else getattr(image_file, "name", None) if not path: raise gr.Error("Could not read uploaded file.") ext = os.path.splitext(path)[1].lower() if ext == ".svg": png_bytes = cairosvg.svg2png(url=path) return Image.open(io.BytesIO(png_bytes)).convert("RGB"), ext return Image.open(path).convert("RGB"), ext def init_segmentation_pipeline(): global seg_pipeline, MODEL_AVAILABLE, MODEL_ERROR if seg_pipeline is not None: return seg_pipeline if not MODEL_AVAILABLE: return None try: seg_pipeline = pipeline( task="image-segmentation", model="nvidia/segformer-b0-finetuned-ade-512-512", device=-1, ) return seg_pipeline except Exception as e: MODEL_ERROR = str(e) return None def preprocess_and_extract_boundary(img_bgr: np.ndarray): gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) blur = cv2.GaussianBlur(gray, (5, 5), 0) _, th = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) kernel = np.ones((5, 5), np.uint8) th = cv2.morphologyEx(th, cv2.MORPH_CLOSE, kernel, iterations=2) th = cv2.morphologyEx(th, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8), iterations=1) contours, _ = cv2.findContours(th, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: raise ValueError("No closed shape detected. Use a darker outline on light background.") contour = max(contours, key=cv2.contourArea) peri = cv2.arcLength(contour, True) approx = cv2.approxPolyDP(contour, 0.01 * peri, True) mask = np.zeros_like(gray) cv2.drawContours(mask, [contour], -1, 255, thickness=cv2.FILLED) return th, contour[:, 0, :], approx[:, 0, :], mask def mask_from_vision_model(image: Image.Image, boundary_mask: np.ndarray): pipe = init_segmentation_pipeline() if pipe is None: return None, f"Vision model unavailable: {MODEL_ERROR}" if MODEL_ERROR else "Vision model unavailable" try: preds = pipe(image) if not preds: return None, "Vision model returned no masks" room_mask = np.zeros(boundary_mask.shape, dtype=np.uint8) for pred in preds: label = str(pred.get("label", "")).lower() if any(k in label for k in ["floor", "rug", "carpet", "room", "interior"]): seg = pred.get("mask") if isinstance(seg, Image.Image): seg = np.array(seg.convert("L")) else: seg = np.array(seg) if seg.ndim == 3: seg = seg[..., 0] seg = cv2.resize(seg.astype(np.uint8), (boundary_mask.shape[1], boundary_mask.shape[0])) room_mask = np.maximum(room_mask, (seg > 0).astype(np.uint8) * 255) room_mask = cv2.bitwise_and(room_mask, boundary_mask) if np.count_nonzero(room_mask) == 0: return None, "Vision model found no interior-like regions" return room_mask, "Vision model used for interior proposal" except Exception as e: return None, f"Vision inference failed: {e}" def axis_aligned_split(bounds, orientation, pos, gap=8): x1, y1, x2, y2 = bounds if orientation == "v": return [(x1, y1, pos - gap, y2), (pos + gap, y1, x2, y2)] return [(x1, y1, x2, pos - gap), (x1, pos + gap, x2, y2)] def rect_inside_mask(rect, mask, fill_ratio=0.72): x1, y1, x2, y2 = [int(v) for v in rect] if x2 <= x1 or y2 <= y1: return False roi = mask[max(0, y1):max(0, y2), max(0, x1):max(0, x2)] if roi.size == 0: return False return (roi > 0).mean() >= fill_ratio def generate_rooms(mask: np.ndarray, cfg: PlanConfig): ys, xs = np.where(mask > 0) x1, y1, x2, y2 = xs.min(), ys.min(), xs.max(), ys.max() rooms = [(x1, y1, x2, y2)] target = {"compact": 6, "balanced": 5, "open": 3}.get(cfg.variation, 5) target = min(target, cfg.max_rooms) for _ in range(target * 4): if len(rooms) >= target: break areas = [max(0, (r[2] - r[0]) * (r[3] - r[1])) for r in rooms] idx = int(np.argmax(areas)) r = rooms.pop(idx) rx1, ry1, rx2, ry2 = r w, h = rx2 - rx1, ry2 - ry1 if w * h < mask.shape[0] * mask.shape[1] * cfg.min_room_area_ratio: rooms.append(r) continue if cfg.variation == "open": orientation = "v" if w > h * 1.25 else "h" cuts = [0.5] elif cfg.variation == "compact": orientation = "h" if h > w else "v" cuts = [0.45, 0.55] else: orientation = "v" if w >= h else "h" cuts = [0.5, 0.4, 0.6] split_done = False for c in cuts: pos = int(rx1 + w * c) if orientation == "v" else int(ry1 + h * c) kids = axis_aligned_split(r, orientation, pos, gap=cfg.wall_thickness) valid = [k for k in kids if rect_inside_mask(k, mask)] if len(valid) == 2: rooms.extend(valid) split_done = True break if not split_done: rooms.append(r) return rooms def polygon_from_mask_component(component_mask): contours, _ = cv2.findContours(component_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return None c = max(contours, key=cv2.contourArea) return c def draw_floorplan(boundary_mask, room_seed_mask, rooms, contour_points, approx_points, cfg: PlanConfig, vision_note: str): h, w = boundary_mask.shape canvas = np.full((h, w, 3), 255, np.uint8) palette = [(239, 246, 255), (236, 253, 245), (255, 247, 237), (250, 245, 255), (254, 242, 242), (240, 253, 250)] room_json = [] for i, r in enumerate(rooms): x1, y1, x2, y2 = [int(v) for v in r] room_mask = np.zeros_like(boundary_mask) cv2.rectangle(room_mask, (x1, y1), (x2, y2), 255, thickness=-1) room_mask = cv2.bitwise_and(room_mask, room_seed_mask) c = polygon_from_mask_component(room_mask) if c is None: continue cv2.drawContours(canvas, [c], -1, palette[i % len(palette)], thickness=cv2.FILLED) cv2.drawContours(canvas, [c], -1, (0, 0, 0), thickness=2) M = cv2.moments(c) if M["m00"]: cx = int(M["m10"] / M["m00"]) cy = int(M["m01"] / M["m00"]) cv2.putText(canvas, f"Room {i+1}", (cx - 28, cy), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (20, 20, 20), 1, cv2.LINE_AA) room_json.append({"id": f"room_{i+1}", "bbox": [x1, y1, x2, y2], "area_px": int(cv2.contourArea(c)), "polygon": c[:, 0, :].tolist()}) cv2.drawContours(canvas, [contour_points.reshape(-1, 1, 2)], -1, (0, 0, 0), thickness=max(3, cfg.wall_thickness + 2)) cv2.putText(canvas, vision_note, (16, 24), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (60, 60, 60), 1, cv2.LINE_AA) result = { "boundary": contour_points.tolist(), "boundary_simplified": approx_points.tolist(), "rooms": room_json, "config": cfg.__dict__, "meta": {"image_size": [int(w), int(h)], "vision_note": vision_note}, } return canvas, result def export_svg(result, width, height): def pts_to_path(pts): if not pts: return "" start = f"M {pts[0][0]} {pts[0][1]}" rest = " ".join([f"L {p[0]} {p[1]}" for p in pts[1:]]) return f"{start} {rest} Z" colors = ["#dbeafe", "#dcfce7", "#ffedd5", "#f3e8ff", "#fee2e2", "#ccfbf1"] parts = [f'', ''] for i, room in enumerate(result["rooms"]): parts.append(f'') parts.append(f'') parts.append('') return "\n".join(parts) def process(image_file, variation, wall_thickness, max_rooms, use_vision_model): image, source_ext = load_any_image(image_file) cfg = PlanConfig(variation=variation, wall_thickness=wall_thickness, max_rooms=max_rooms, use_vision_model=use_vision_model) img_bgr = pil_to_bgr(image) binary, contour, approx, boundary_mask = preprocess_and_extract_boundary(img_bgr) room_seed_mask = boundary_mask.copy() vision_note = "Heuristic partition mode" if use_vision_model: proposed_mask, note = mask_from_vision_model(image, boundary_mask) if proposed_mask is not None: room_seed_mask = proposed_mask vision_note = note rooms = generate_rooms(room_seed_mask, cfg) rendered, result = draw_floorplan(boundary_mask, room_seed_mask, rooms, contour, approx, cfg, vision_note) result["meta"]["source_ext"] = source_ext svg = export_svg(result, rendered.shape[1], rendered.shape[0]) json_str = json.dumps(result, indent=2) preview = bgr_to_pil(rendered) binary_preview = Image.fromarray(room_seed_mask) tmpdir = Path(tempfile.mkdtemp(prefix="floorplan_space_")) zip_path = tmpdir / "floorplan_bundle.zip" with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: zf.writestr("floorplan.json", json_str) zf.writestr("floorplan.svg", svg) return preview, binary_preview, json_str, str(zip_path) with gr.Blocks(title="Closed Shape to Floor Plan") as demo: gr.Markdown( "# Closed Shape to Floor Plan\n" "Upload a closed-outline PNG, JPG, or SVG. Enable the vision-model option to let a Hugging Face segmentation model propose interior regions before room partitioning." ) with gr.Row(): with gr.Column(scale=1): inp = gr.File(file_types=[".png", ".jpg", ".jpeg", ".svg"], label="Closed shape file (PNG/JPG/SVG)") variation = gr.Radio(["balanced", "compact", "open"], value="balanced", label="Variation") wall_thickness = gr.Slider(2, 16, value=6, step=1, label="Wall thickness") max_rooms = gr.Slider(2, 8, value=5, step=1, label="Max rooms") use_vision_model = gr.Checkbox(value=True, label="Use vision model for interior proposal") btn = gr.Button("Generate floor plan", variant="primary") with gr.Column(scale=1): out = gr.Image(label="Generated floor plan") binary = gr.Image(label="Interior / room seed mask") structured = gr.Code(label="Structured JSON", language="json") archive = gr.File(label="Download JSON + SVG") btn.click(process, inputs=[inp, variation, wall_thickness, max_rooms, use_vision_model], outputs=[out, binary, structured, archive]) if __name__ == "__main__": demo.launch(theme=gr.themes.Soft(), ssr_mode=False)