rikhoffbauer2 commited on
Commit
78b9c5f
Β·
verified Β·
1 Parent(s): a51ee55

Upload floorplan/renderer.py

Browse files
Files changed (1) hide show
  1. floorplan/renderer.py +307 -0
floorplan/renderer.py ADDED
@@ -0,0 +1,307 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Floor plan renderer β€” render a FloorPlan schema to SVG and/or PIL Image.
3
+
4
+ Two rendering paths:
5
+ 1. SVG string output (always available, for file export / browser display)
6
+ 2. PIL Image output (rasterizes via PIL ImageDraw β€” no system deps needed)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import io
12
+ import math
13
+ from typing import Optional
14
+
15
+ import numpy as np
16
+ from PIL import Image, ImageDraw, ImageFont
17
+ from shapely.geometry import Polygon, MultiPolygon
18
+
19
+ from .schema import FloorPlan, Wall, Opening, OpeningType, Room, RoomLabel
20
+ from .geometry import (
21
+ wall_to_polygon,
22
+ wall_polygon_with_openings,
23
+ detect_rooms_from_walls,
24
+ interpolate_along_centerline,
25
+ normal_at_distance,
26
+ compute_centerline_length,
27
+ )
28
+
29
+
30
+ # ── Color scheme ──
31
+
32
+ ROOM_COLORS: dict[RoomLabel, str] = {
33
+ RoomLabel.BEDROOM: "#81C784",
34
+ RoomLabel.BATHROOM: "#64B5F6",
35
+ RoomLabel.KITCHEN: "#FFB74D",
36
+ RoomLabel.LIVING_ROOM: "#CE93D8",
37
+ RoomLabel.DINING_ROOM: "#F48FB1",
38
+ RoomLabel.HALLWAY: "#E0E0E0",
39
+ RoomLabel.CLOSET: "#BCAAA4",
40
+ RoomLabel.LAUNDRY: "#80CBC4",
41
+ RoomLabel.GARAGE: "#B0BEC5",
42
+ RoomLabel.BALCONY: "#A5D6A7",
43
+ RoomLabel.OFFICE: "#90CAF9",
44
+ RoomLabel.STORAGE: "#D7CCC8",
45
+ RoomLabel.ENTRANCE: "#FFF59D",
46
+ RoomLabel.OTHER: "#CFD8DC",
47
+ RoomLabel.UNKNOWN: "#E8EAF6",
48
+ }
49
+
50
+ WALL_COLOR = "#37474F"
51
+ WALL_STROKE = "#263238"
52
+ DOOR_COLOR = "#FF7043"
53
+ WINDOW_COLOR = "#29B6F6"
54
+ ROOM_LABEL_COLOR = "#212121"
55
+
56
+
57
+ def _hex_to_rgba(hex_color: str, alpha: int = 255) -> tuple[int, int, int, int]:
58
+ h = hex_color.lstrip("#")
59
+ r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
60
+ return (r, g, b, alpha)
61
+
62
+
63
+ def _shapely_poly_to_xy(poly: Polygon) -> list[tuple[float, float]]:
64
+ return list(poly.exterior.coords)
65
+
66
+
67
+ # ──────────────────────────────────────────────
68
+ # SVG Renderer
69
+ # ──────────────────────────────────────────────
70
+
71
+ def render_floorplan_svg(
72
+ floorplan: FloorPlan,
73
+ width: int = 1024, height: int = 1024, padding: float = 0.5,
74
+ show_rooms: bool = True, show_openings: bool = True, show_labels: bool = True,
75
+ wall_opacity: float = 0.85, room_opacity: float = 0.3,
76
+ room_polygons: Optional[list[Polygon]] = None,
77
+ ) -> str:
78
+ """Render a FloorPlan to an SVG string."""
79
+ if not floorplan.walls:
80
+ return f'<svg width="{width}" height="{height}" xmlns="http://www.w3.org/2000/svg"></svg>'
81
+ bbox = _compute_bbox(floorplan, padding)
82
+ scale, tx, ty = _compute_transform(bbox, width, height)
83
+ parts = [
84
+ f'<svg width="{width}" height="{height}" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}">',
85
+ f' <rect width="{width}" height="{height}" fill="white" />',
86
+ ]
87
+ if show_rooms:
88
+ if room_polygons is None:
89
+ room_polygons = detect_rooms_from_walls(floorplan.walls)
90
+ for i, rpoly in enumerate(room_polygons):
91
+ label = RoomLabel.UNKNOWN
92
+ if i < len(floorplan.rooms):
93
+ label = floorplan.rooms[i].label or RoomLabel.UNKNOWN
94
+ color = ROOM_COLORS.get(label, ROOM_COLORS[RoomLabel.UNKNOWN])
95
+ coords = [(tx(x), ty(y)) for x, y in rpoly.exterior.coords]
96
+ d = "M " + " L ".join(f"{x:.1f} {y:.1f}" for x, y in coords) + " Z"
97
+ parts.append(f' <path d="{d}" fill="{color}" opacity="{room_opacity}" />')
98
+ if show_labels:
99
+ cx, cy = tx(rpoly.centroid.x), ty(rpoly.centroid.y)
100
+ lt = label.value.replace("_", " ").title() if label != RoomLabel.UNKNOWN else "?"
101
+ fs = max(10, scale * 0.25)
102
+ parts.append(f' <text x="{cx:.0f}" y="{cy:.0f}" text-anchor="middle" dominant-baseline="central" font-family="sans-serif" font-size="{fs:.0f}" fill="{ROOM_LABEL_COLOR}" font-weight="bold">{lt}</text>')
103
+ parts.append(f' <text x="{cx:.0f}" y="{cy + fs * 1.2:.0f}" text-anchor="middle" dominant-baseline="central" font-family="sans-serif" font-size="{fs * 0.7:.0f}" fill="{ROOM_LABEL_COLOR}" opacity="0.6">{rpoly.area:.1f} m\u00b2</text>')
104
+ for wall in floorplan.walls:
105
+ geom = wall_polygon_with_openings(wall) if (wall.openings and show_openings) else wall_to_polygon(wall)
106
+ polys = [geom] if isinstance(geom, Polygon) else list(geom.geoms) if isinstance(geom, MultiPolygon) else []
107
+ for poly in polys:
108
+ coords = [(tx(x), ty(y)) for x, y in poly.exterior.coords]
109
+ d = "M " + " L ".join(f"{x:.1f} {y:.1f}" for x, y in coords) + " Z"
110
+ for interior in poly.interiors:
111
+ hc = [(tx(x), ty(y)) for x, y in interior.coords]
112
+ d += " M " + " L ".join(f"{x:.1f} {y:.1f}" for x, y in hc) + " Z"
113
+ parts.append(f' <path d="{d}" fill="{WALL_COLOR}" opacity="{wall_opacity}" stroke="{WALL_STROKE}" stroke-width="0.5" fill-rule="evenodd" />')
114
+ if show_openings:
115
+ for wall in floorplan.walls:
116
+ for opening in wall.openings:
117
+ coords = wall.centerline_coords
118
+ half_t = wall.thickness / 2.0
119
+ s = interpolate_along_centerline(coords, opening.start)
120
+ e = interpolate_along_centerline(coords, opening.start + opening.length)
121
+ n = normal_at_distance(coords, opening.start + opening.length / 2)
122
+ color = DOOR_COLOR if opening.type == OpeningType.DOOR else WINDOW_COLOR
123
+ sw = max(1.5, wall.thickness * scale * 0.25)
124
+ sx, sy, ex, ey = tx(s[0]), ty(s[1]), tx(e[0]), ty(e[1])
125
+ parts.append(f' <line x1="{sx:.1f}" y1="{sy:.1f}" x2="{ex:.1f}" y2="{ey:.1f}" stroke="{color}" stroke-width="{sw:.1f}" stroke-linecap="round" />')
126
+ if opening.type == OpeningType.WINDOW:
127
+ off = half_t * 0.4
128
+ for sign in [-1, 1]:
129
+ parts.append(f' <line x1="{tx(s[0]+n[0]*off*sign):.1f}" y1="{ty(s[1]+n[1]*off*sign):.1f}" x2="{tx(e[0]+n[0]*off*sign):.1f}" y2="{ty(e[1]+n[1]*off*sign):.1f}" stroke="{WINDOW_COLOR}" stroke-width="{sw*0.3:.1f}" opacity="0.7" />')
130
+ if opening.type == OpeningType.DOOR:
131
+ tick = half_t * 0.8
132
+ for pt in [s, e]:
133
+ parts.append(f' <line x1="{tx(pt[0]+n[0]*tick):.1f}" y1="{ty(pt[1]+n[1]*tick):.1f}" x2="{tx(pt[0]-n[0]*tick):.1f}" y2="{ty(pt[1]-n[1]*tick):.1f}" stroke="{color}" stroke-width="{sw*0.5:.1f}" />')
134
+ parts.append('</svg>')
135
+ return "\n".join(parts)
136
+
137
+
138
+ # ──────────────────────────────────────────────
139
+ # PIL Renderer
140
+ # ──────────────────────────────────────────────
141
+
142
+ def render_to_image(
143
+ floorplan: FloorPlan,
144
+ width: int = 1024, height: int = 1024, padding: float = 0.5,
145
+ show_rooms: bool = True, show_openings: bool = True, show_labels: bool = True,
146
+ wall_opacity: float = 0.85, room_opacity: float = 0.3,
147
+ room_polygons: Optional[list[Polygon]] = None, background: str = "white",
148
+ ) -> Image.Image:
149
+ """Render floor plan to a PIL RGBA Image using ImageDraw."""
150
+ if not floorplan.walls:
151
+ return Image.new("RGBA", (width, height), background)
152
+ bbox = _compute_bbox(floorplan, padding)
153
+ scale, tx, ty = _compute_transform(bbox, width, height)
154
+ img = Image.new("RGBA", (width, height), (255, 255, 255, 255))
155
+ if show_rooms:
156
+ if room_polygons is None:
157
+ room_polygons = detect_rooms_from_walls(floorplan.walls)
158
+ room_layer = Image.new("RGBA", (width, height), (0, 0, 0, 0))
159
+ room_draw = ImageDraw.Draw(room_layer)
160
+ for i, rpoly in enumerate(room_polygons):
161
+ label = RoomLabel.UNKNOWN
162
+ if i < len(floorplan.rooms):
163
+ label = floorplan.rooms[i].label or RoomLabel.UNKNOWN
164
+ color = ROOM_COLORS.get(label, ROOM_COLORS[RoomLabel.UNKNOWN])
165
+ rgba = _hex_to_rgba(color, int(255 * room_opacity))
166
+ coords = [(tx(x), ty(y)) for x, y in rpoly.exterior.coords]
167
+ room_draw.polygon(coords, fill=rgba)
168
+ img = Image.alpha_composite(img, room_layer)
169
+ wall_layer = Image.new("RGBA", (width, height), (0, 0, 0, 0))
170
+ wall_draw = ImageDraw.Draw(wall_layer)
171
+ wall_rgba = _hex_to_rgba(WALL_COLOR, int(255 * wall_opacity))
172
+ wall_stroke_rgba = _hex_to_rgba(WALL_STROKE, int(255 * wall_opacity))
173
+ for wall in floorplan.walls:
174
+ geom = wall_polygon_with_openings(wall) if (wall.openings and show_openings) else wall_to_polygon(wall)
175
+ polys = [geom] if isinstance(geom, Polygon) else list(geom.geoms) if isinstance(geom, MultiPolygon) else []
176
+ for poly in polys:
177
+ coords = [(tx(x), ty(y)) for x, y in poly.exterior.coords]
178
+ wall_draw.polygon(coords, fill=wall_rgba, outline=wall_stroke_rgba)
179
+ img = Image.alpha_composite(img, wall_layer)
180
+ if show_openings:
181
+ opening_layer = Image.new("RGBA", (width, height), (0, 0, 0, 0))
182
+ opening_draw = ImageDraw.Draw(opening_layer)
183
+ for wall in floorplan.walls:
184
+ for opening in wall.openings:
185
+ coords = wall.centerline_coords
186
+ half_t = wall.thickness / 2.0
187
+ s = interpolate_along_centerline(coords, opening.start)
188
+ e = interpolate_along_centerline(coords, opening.start + opening.length)
189
+ n = normal_at_distance(coords, opening.start + opening.length / 2)
190
+ color = DOOR_COLOR if opening.type == OpeningType.DOOR else WINDOW_COLOR
191
+ rgba = _hex_to_rgba(color, 230)
192
+ lw = max(2, int(wall.thickness * scale * 0.25))
193
+ sx, sy, ex, ey = tx(s[0]), ty(s[1]), tx(e[0]), ty(e[1])
194
+ opening_draw.line([(sx, sy), (ex, ey)], fill=rgba, width=lw)
195
+ if opening.type == OpeningType.WINDOW:
196
+ off = half_t * 0.4
197
+ win_rgba = _hex_to_rgba(WINDOW_COLOR, 180)
198
+ for sign in [-1, 1]:
199
+ opening_draw.line(
200
+ [(tx(s[0]+n[0]*off*sign), ty(s[1]+n[1]*off*sign)),
201
+ (tx(e[0]+n[0]*off*sign), ty(e[1]+n[1]*off*sign))],
202
+ fill=win_rgba, width=max(1, lw // 3))
203
+ if opening.type == OpeningType.DOOR:
204
+ tick = half_t * 0.8
205
+ for pt in [s, e]:
206
+ opening_draw.line(
207
+ [(tx(pt[0]+n[0]*tick), ty(pt[1]+n[1]*tick)),
208
+ (tx(pt[0]-n[0]*tick), ty(pt[1]-n[1]*tick))],
209
+ fill=rgba, width=max(1, lw // 2))
210
+ img = Image.alpha_composite(img, opening_layer)
211
+ if show_labels:
212
+ label_draw = ImageDraw.Draw(img)
213
+ try:
214
+ font_large = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", max(10, int(scale * 0.25)))
215
+ font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", max(8, int(scale * 0.18)))
216
+ font_tiny = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", max(7, int(scale * 0.12)))
217
+ except (OSError, IOError):
218
+ font_large = ImageFont.load_default()
219
+ font_small = font_large
220
+ font_tiny = font_large
221
+ if show_rooms and room_polygons:
222
+ for i, rpoly in enumerate(room_polygons):
223
+ label = RoomLabel.UNKNOWN
224
+ if i < len(floorplan.rooms):
225
+ label = floorplan.rooms[i].label or RoomLabel.UNKNOWN
226
+ lt = label.value.replace("_", " ").title() if label != RoomLabel.UNKNOWN else "?"
227
+ cx, cy = tx(rpoly.centroid.x), ty(rpoly.centroid.y)
228
+ label_draw.text((cx, cy), lt, fill=ROOM_LABEL_COLOR, font=font_large, anchor="mm")
229
+ label_draw.text((cx, cy + scale * 0.35), f"{rpoly.area:.1f} m\u00b2", fill=(33, 33, 33, 150), font=font_small, anchor="mm")
230
+ for wall in floorplan.walls:
231
+ cl = wall.centerline_coords
232
+ mid_d = compute_centerline_length(cl) / 2
233
+ mid_pt = interpolate_along_centerline(cl, mid_d)
234
+ n = normal_at_distance(cl, mid_d)
235
+ off = wall.thickness * 0.8 + 0.15
236
+ lx, ly = tx(mid_pt[0] + n[0] * off), ty(mid_pt[1] + n[1] * off)
237
+ label_draw.text((lx, ly), wall.id, fill=(120, 144, 156, 180), font=font_tiny, anchor="mm")
238
+ if show_openings:
239
+ for wall in floorplan.walls:
240
+ for opening in wall.openings:
241
+ coords = wall.centerline_coords
242
+ s = interpolate_along_centerline(coords, opening.start)
243
+ e = interpolate_along_centerline(coords, opening.start + opening.length)
244
+ n = normal_at_distance(coords, opening.start + opening.length / 2)
245
+ mx = (tx(s[0]) + tx(e[0])) / 2
246
+ my = (ty(s[1]) + ty(e[1])) / 2
247
+ off = wall.thickness * scale * 0.6
248
+ color = DOOR_COLOR if opening.type == OpeningType.DOOR else WINDOW_COLOR
249
+ label_draw.text((mx + n[0]*off, my + n[1]*off), opening.id, fill=color, font=font_tiny, anchor="mm")
250
+ return img
251
+
252
+
253
+ def overlay_on_image(
254
+ floorplan: FloorPlan,
255
+ original_image: Image.Image | str,
256
+ schema_opacity: float = 0.55,
257
+ original_opacity: float = 0.7,
258
+ room_polygons: Optional[list[Polygon]] = None,
259
+ **kwargs,
260
+ ) -> Image.Image:
261
+ """Render the floor plan schema and overlay it on the original image."""
262
+ if isinstance(original_image, str):
263
+ original_image = Image.open(original_image)
264
+ original_image = original_image.convert("RGBA")
265
+ w, h = original_image.size
266
+ rendered = render_to_image(floorplan, width=w, height=h, room_opacity=0.2, wall_opacity=0.7, room_polygons=room_polygons, **kwargs)
267
+ r, g, b, a = rendered.split()
268
+ a_array = np.array(a).astype(float)
269
+ a_array = (a_array * schema_opacity).clip(0, 255).astype(np.uint8)
270
+ rendered.putalpha(Image.fromarray(a_array))
271
+ r_o, g_o, b_o, a_o = original_image.split()
272
+ a_o_array = np.array(a_o).astype(float)
273
+ a_o_array = (a_o_array * original_opacity).clip(0, 255).astype(np.uint8)
274
+ original_dim = original_image.copy()
275
+ original_dim.putalpha(Image.fromarray(a_o_array))
276
+ result = Image.new("RGBA", (w, h), (255, 255, 255, 255))
277
+ result = Image.alpha_composite(result, original_dim)
278
+ result = Image.alpha_composite(result, rendered)
279
+ return result
280
+
281
+
282
+ # ──────────────────────────────────────────────
283
+ # Shared helpers
284
+ # ──────────────────────────────────────────────
285
+
286
+ def _compute_bbox(floorplan: FloorPlan, padding: float) -> tuple[float, float, float, float]:
287
+ all_coords = []
288
+ for wall in floorplan.walls:
289
+ poly = wall_to_polygon(wall)
290
+ all_coords.extend(poly.exterior.coords)
291
+ xs = [c[0] for c in all_coords]
292
+ ys = [c[1] for c in all_coords]
293
+ return (min(xs) - padding, min(ys) - padding, max(xs) + padding, max(ys) + padding)
294
+
295
+
296
+ def _compute_transform(bbox, width, height):
297
+ min_x, min_y, max_x, max_y = bbox
298
+ span_x = max_x - min_x
299
+ span_y = max_y - min_y
300
+ scale = min(width / span_x, height / span_y)
301
+ used_w = span_x * scale
302
+ used_h = span_y * scale
303
+ offset_x = (width - used_w) / 2
304
+ offset_y = (height - used_h) / 2
305
+ def tx(x): return (x - min_x) * scale + offset_x
306
+ def ty(y): return (y - min_y) * scale + offset_y
307
+ return scale, tx, ty