Pream912 commited on
Commit
03b3a82
Β·
verified Β·
1 Parent(s): da1790a

Create wall_pipeline.py

Browse files
Files changed (1) hide show
  1. wall_pipeline.py +913 -0
wall_pipeline.py ADDED
@@ -0,0 +1,913 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Wall Extraction Pipeline β€” GPU-aware, self-contained
3
+ All heavy NumPy ops are dispatched to GPU (CuPy) when available,
4
+ falling back transparently to CPU NumPy.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import numpy as np
9
+ import cv2
10
+ from dataclasses import dataclass
11
+ from typing import List, Dict, Any, Tuple, Optional
12
+
13
+ # ── GPU shim ────────────────────────────────────────────────────────────────
14
+ try:
15
+ import cupy as cp
16
+ _GPU = True
17
+ print("[GPU] CuPy available β€” GPU acceleration ON")
18
+ except ImportError:
19
+ cp = np # type: ignore
20
+ _GPU = False
21
+ print("[GPU] CuPy not found β€” running on CPU")
22
+
23
+ try:
24
+ from skimage.morphology import skeletonize as _sk_skel
25
+ _SKIMAGE = True
26
+ except ImportError:
27
+ _SKIMAGE = False
28
+
29
+ try:
30
+ from scipy.spatial import cKDTree
31
+ _SCIPY = True
32
+ except ImportError:
33
+ _SCIPY = False
34
+
35
+
36
+ def _to_gpu(arr: np.ndarray):
37
+ return cp.asarray(arr) if _GPU else arr
38
+
39
+ def _to_cpu(arr) -> np.ndarray:
40
+ return cp.asnumpy(arr) if _GPU else arr
41
+
42
+
43
+ # ── Calibration dataclass ────────────────────────────────────────────────────
44
+ @dataclass
45
+ class WallCalibration:
46
+ stroke_width : int = 3
47
+ min_component_dim : int = 30
48
+ min_component_area: int = 45
49
+ bridge_min_gap : int = 2
50
+ bridge_max_gap : int = 14
51
+ door_gap : int = 41
52
+ max_bridge_thick : int = 15
53
+
54
+ def as_dict(self):
55
+ return {
56
+ "stroke_width" : self.stroke_width,
57
+ "min_component_dim" : self.min_component_dim,
58
+ "min_component_area": self.min_component_area,
59
+ "bridge_min_gap" : self.bridge_min_gap,
60
+ "bridge_max_gap" : self.bridge_max_gap,
61
+ "door_gap" : self.door_gap,
62
+ "max_bridge_thick" : self.max_bridge_thick,
63
+ }
64
+
65
+
66
+ # ── RLE helpers ──────────────────────────────────────────────────────────────
67
+ def mask_to_rle(mask: np.ndarray) -> Dict[str, Any]:
68
+ h, w = mask.shape
69
+ flat = mask.flatten(order='F').astype(bool)
70
+ counts: List[int] = []
71
+ current_val = False
72
+ run = 0
73
+ for v in flat:
74
+ if v == current_val:
75
+ run += 1
76
+ else:
77
+ counts.append(run)
78
+ run = 1
79
+ current_val = v
80
+ counts.append(run)
81
+ if mask[0, 0]:
82
+ counts.insert(0, 0)
83
+ return {"counts": counts, "size": [h, w]}
84
+
85
+
86
+ def _mask_to_contour_flat(mask: np.ndarray) -> List[float]:
87
+ contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
88
+ if not contours:
89
+ return []
90
+ largest = max(contours, key=cv2.contourArea)
91
+ pts = largest[:, 0, :].tolist()
92
+ return [v for pt in pts for v in pt]
93
+
94
+
95
+ # ── Core pipeline class ───────────────────────────────────────────────────────
96
+ class WallPipeline:
97
+ """
98
+ Stateless (per-call) wall extraction + room segmentation.
99
+ All intermediate images are returned in a log dict for the UI.
100
+ """
101
+
102
+ MIN_ROOM_AREA_FRAC = 0.000004
103
+ MAX_ROOM_AREA_FRAC = 0.08
104
+ MIN_ROOM_DIM_FRAC = 0.01
105
+ BORDER_MARGIN_FRAC = 0.01
106
+ MAX_ASPECT_RATIO = 8.0
107
+ MIN_SOLIDITY = 0.25
108
+ MIN_EXTENT = 0.08
109
+
110
+ FIXTURE_MAX_BLOB_DIM = 80
111
+ FIXTURE_MAX_AREA = 4000
112
+ FIXTURE_MAX_ASPECT = 4.0
113
+ FIXTURE_DENSITY_RADIUS = 50
114
+ FIXTURE_DENSITY_THRESHOLD = 0.35
115
+ FIXTURE_MIN_ZONE_AREA = 1500
116
+
117
+ DOOR_ARC_MIN_RADIUS = 60
118
+ DOOR_ARC_MAX_RADIUS = 320
119
+
120
+ def __init__(self, progress_cb=None):
121
+ self.progress_cb = progress_cb or (lambda msg, pct: None)
122
+ self._wall_cal: Optional[WallCalibration] = None
123
+ self._wall_thickness = 8
124
+ self.stage_images: Dict[str, np.ndarray] = {}
125
+
126
+ def _log(self, msg: str, pct: int):
127
+ self.progress_cb(msg, pct)
128
+
129
+ def _save(self, key: str, img: np.ndarray):
130
+ self.stage_images[key] = img.copy()
131
+
132
+ # ── Public entry point ────────────────────────────────────────────────────
133
+ def run(self, img_bgr: np.ndarray,
134
+ extra_door_lines: List[Tuple[int,int,int,int]] = None
135
+ ) -> Tuple[np.ndarray, np.ndarray, WallCalibration]:
136
+ """
137
+ Returns (wall_mask, room_mask, calibration).
138
+ extra_door_lines: list of (x1,y1,x2,y2) to paint onto wall mask before room seg.
139
+ """
140
+ self.stage_images = {}
141
+ self._log("Step 1 β€” Removing title block", 5)
142
+ img = self._remove_title_block(img_bgr)
143
+ self._save("01_title_removed", img)
144
+
145
+ self._log("Step 2 β€” Removing colored annotations", 12)
146
+ img = self._remove_colors(img)
147
+ self._save("02_colors_removed", img)
148
+
149
+ self._log("Step 3 β€” Closing door arcs", 20)
150
+ img = self._close_door_arcs(img)
151
+ self._save("03_door_arcs", img)
152
+
153
+ self._log("Step 4 β€” Extracting walls", 30)
154
+ walls = self._extract_walls(img)
155
+ self._save("04_walls_raw", walls)
156
+
157
+ self._log("Step 5b β€” Removing fixture symbols", 38)
158
+ walls = self._remove_fixtures(walls)
159
+ self._save("05b_no_fixtures", walls)
160
+
161
+ self._log("Step 5c β€” Calibrating & removing thin lines", 45)
162
+ self._wall_cal = self._calibrate_wall(walls)
163
+ walls = self._remove_thin_lines_calibrated(walls)
164
+ self._save("05c_thin_removed", walls)
165
+
166
+ self._log("Step 5d β€” Bridging wall endpoints", 55)
167
+ walls = self._bridge_endpoints(walls)
168
+ self._save("05d_bridged", walls)
169
+
170
+ self._log("Step 5e β€” Closing door openings", 63)
171
+ walls = self._close_door_openings(walls)
172
+ self._save("05e_doors_closed", walls)
173
+
174
+ self._log("Step 5f β€” Removing dangling lines", 70)
175
+ walls = self._remove_dangling(walls)
176
+ self._save("05f_dangling_removed", walls)
177
+
178
+ self._log("Step 5g β€” Sealing large door gaps", 76)
179
+ walls = self._close_large_gaps(walls)
180
+ self._save("05g_large_gaps", walls)
181
+
182
+ # Paint extra door-seal lines from UI
183
+ if extra_door_lines:
184
+ self._log("Applying manual door seal lines", 79)
185
+ lw = max(3, self._wall_cal.stroke_width if self._wall_cal else 3)
186
+ for x1, y1, x2, y2 in extra_door_lines:
187
+ cv2.line(walls, (x1, y1), (x2, y2), 255, lw)
188
+ self._save("05h_manual_doors", walls)
189
+
190
+ self._log("Step 7 β€” Flood-fill room segmentation", 85)
191
+ rooms = self._segment_rooms(walls)
192
+ self._save("07_rooms", rooms)
193
+
194
+ self._log("Step 8 β€” Filtering room regions", 93)
195
+ valid_mask, _ = self._filter_rooms(rooms, img_bgr.shape)
196
+ self._save("08_rooms_filtered", valid_mask)
197
+
198
+ self._log("Done", 100)
199
+ return walls, valid_mask, self._wall_cal
200
+
201
+ # ── Stage 1: Remove title block ───────────────────────────────────────────
202
+ def _remove_title_block(self, img: np.ndarray) -> np.ndarray:
203
+ h, w = img.shape[:2]
204
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
205
+ edges = cv2.Canny(gray, 50, 150)
206
+ h_kern = cv2.getStructuringElement(cv2.MORPH_RECT, (w // 20, 1))
207
+ v_kern = cv2.getStructuringElement(cv2.MORPH_RECT, (1, h // 20))
208
+ h_lines = cv2.morphologyEx(edges, cv2.MORPH_OPEN, h_kern)
209
+ v_lines = cv2.morphologyEx(edges, cv2.MORPH_OPEN, v_kern)
210
+ crop_right, crop_bottom = w, h
211
+ right_region = v_lines[:, int(w * 0.7):]
212
+ if np.any(right_region):
213
+ vp = np.where(np.sum(right_region, axis=0) > h * 0.3)[0]
214
+ if len(vp):
215
+ crop_right = int(w * 0.7) + vp[0] - 10
216
+ bot_region = h_lines[int(h * 0.7):, :]
217
+ if np.any(bot_region):
218
+ hp = np.where(np.sum(bot_region, axis=1) > w * 0.3)[0]
219
+ if len(hp):
220
+ crop_bottom = int(h * 0.7) + hp[0] - 10
221
+ return img[:crop_bottom, :crop_right].copy()
222
+
223
+ # ── Stage 2: Remove colors ────────────────────────────────────────────────
224
+ def _remove_colors(self, img: np.ndarray) -> np.ndarray:
225
+ if _GPU:
226
+ g_img = _to_gpu(img.astype(np.int32))
227
+ b, gch, r = g_img[:,:,0], g_img[:,:,1], g_img[:,:,2]
228
+ gray = (0.114*b + 0.587*gch + 0.299*r)
229
+ chroma = cp.maximum(cp.maximum(r,gch),b) - cp.minimum(cp.minimum(r,gch),b)
230
+ erase = (chroma > 15) & (gray < 240)
231
+ result = _to_gpu(img.copy())
232
+ result[erase] = cp.array([255,255,255], dtype=cp.uint8)
233
+ return _to_cpu(result)
234
+ else:
235
+ b = img[:,:,0].astype(np.int32)
236
+ g = img[:,:,1].astype(np.int32)
237
+ r = img[:,:,2].astype(np.int32)
238
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY).astype(np.int32)
239
+ chroma = np.maximum(np.maximum(r,g),b) - np.minimum(np.minimum(r,g),b)
240
+ erase = (chroma > 15) & (gray < 240)
241
+ result = img.copy()
242
+ result[erase] = (255, 255, 255)
243
+ return result
244
+
245
+ # ── Stage 3: Close door arcs ──────────────────────────────────────────────
246
+ def _close_door_arcs(self, img: np.ndarray) -> np.ndarray:
247
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
248
+ h, w = gray.shape
249
+ result = img.copy()
250
+ _, binary = cv2.threshold(gray, 0, 255,
251
+ cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
252
+ binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, np.ones((3,3), np.uint8))
253
+ blurred = cv2.GaussianBlur(gray, (7,7), 1.5)
254
+ raw = cv2.HoughCircles(blurred, cv2.HOUGH_GRADIENT,
255
+ dp=1.2, minDist=50, param1=50, param2=22,
256
+ minRadius=self.DOOR_ARC_MIN_RADIUS,
257
+ maxRadius=self.DOOR_ARC_MAX_RADIUS)
258
+ if raw is None:
259
+ return result
260
+ circles = np.round(raw[0]).astype(np.int32)
261
+ for cx, cy, r in circles:
262
+ angles, xs, ys = (np.linspace(0, 2*np.pi, 360, endpoint=False),
263
+ None, None)
264
+ xs = np.clip((cx + r*np.cos(angles)).astype(np.int32), 0, w-1)
265
+ ys = np.clip((cy + r*np.sin(angles)).astype(np.int32), 0, h-1)
266
+ on_wall = binary[ys, xs] > 0
267
+ if not np.any(on_wall):
268
+ continue
269
+ occ = angles[on_wall]
270
+ span = float(np.degrees(occ[-1] - occ[0]))
271
+ if not (60 <= span <= 115):
272
+ continue
273
+ leaf_r = r * 0.92
274
+ n_pts = max(60, int(r))
275
+ la = np.linspace(0, 2*np.pi, n_pts, endpoint=False)
276
+ lx = np.clip((cx + leaf_r*np.cos(la)).astype(np.int32), 0, w-1)
277
+ ly = np.clip((cy + leaf_r*np.sin(la)).astype(np.int32), 0, h-1)
278
+ if float(np.mean(binary[ly, lx] > 0)) < 0.35:
279
+ continue
280
+ gap_thresh = np.radians(25.0)
281
+ diffs = np.diff(occ)
282
+ big = np.where(diffs > gap_thresh)[0]
283
+ if len(big) == 0:
284
+ start_a, end_a = occ[0], occ[-1]
285
+ else:
286
+ split = big[np.argmax(diffs[big])]
287
+ start_a, end_a = occ[split+1], occ[split]
288
+ ep1 = (int(round(cx + r*np.cos(start_a))),
289
+ int(round(cy + r*np.sin(start_a))))
290
+ ep2 = (int(round(cx + r*np.cos(end_a))),
291
+ int(round(cy + r*np.sin(end_a))))
292
+ ep1 = (np.clip(ep1[0],0,w-1), np.clip(ep1[1],0,h-1))
293
+ ep2 = (np.clip(ep2[0],0,w-1), np.clip(ep2[1],0,h-1))
294
+ cv2.line(result, ep1, ep2, (0,0,0), 3)
295
+ return result
296
+
297
+ # ── Stage 4: Extract walls ────────────────────────────────────────────────
298
+ def _extract_walls(self, img: np.ndarray) -> np.ndarray:
299
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
300
+ h, w = gray.shape
301
+
302
+ otsu_val, _ = cv2.threshold(gray, 0, 255,
303
+ cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
304
+ brightness = float(np.mean(gray))
305
+ if brightness > 220:
306
+ thr = max(200, int(otsu_val * 1.1))
307
+ elif brightness < 180:
308
+ thr = max(150, int(otsu_val * 0.9))
309
+ else:
310
+ thr = int(otsu_val)
311
+
312
+ _, binary = cv2.threshold(gray, thr, 255, cv2.THRESH_BINARY_INV)
313
+
314
+ min_line = max(8, int(0.012 * w))
315
+ body_thickness = self._estimate_wall_thickness(binary)
316
+ body_thickness = int(np.clip(body_thickness, 9, 30))
317
+ self._wall_thickness = body_thickness
318
+
319
+ k_h = cv2.getStructuringElement(cv2.MORPH_RECT, (min_line, 1))
320
+ k_v = cv2.getStructuringElement(cv2.MORPH_RECT, (1, min_line))
321
+
322
+ if _GPU:
323
+ # GPU morphology via cupy β€” simulate with erosion+dilation
324
+ g_bin = _to_gpu(binary)
325
+ long_h = _to_cpu(cp.asarray(
326
+ cv2.morphologyEx(binary, cv2.MORPH_OPEN, k_h)))
327
+ long_v = _to_cpu(cp.asarray(
328
+ cv2.morphologyEx(binary, cv2.MORPH_OPEN, k_v)))
329
+ else:
330
+ long_h = cv2.morphologyEx(binary, cv2.MORPH_OPEN, k_h)
331
+ long_v = cv2.morphologyEx(binary, cv2.MORPH_OPEN, k_v)
332
+
333
+ orig_walls = cv2.bitwise_or(long_h, long_v)
334
+ k_bh = cv2.getStructuringElement(cv2.MORPH_RECT, (1, body_thickness))
335
+ k_bv = cv2.getStructuringElement(cv2.MORPH_RECT, (body_thickness, 1))
336
+ dilated_h = cv2.dilate(long_h, k_bh)
337
+ dilated_v = cv2.dilate(long_v, k_bv)
338
+ walls = cv2.bitwise_or(dilated_h, dilated_v)
339
+ collision = cv2.bitwise_and(dilated_h, dilated_v)
340
+ safe_zone = cv2.bitwise_and(collision, orig_walls)
341
+ walls = cv2.bitwise_or(
342
+ cv2.bitwise_and(walls, cv2.bitwise_not(collision)), safe_zone)
343
+ dist = cv2.distanceTransform(cv2.bitwise_not(orig_walls), cv2.DIST_L2, 5)
344
+ keep_mask = (dist <= (body_thickness / 2)).astype(np.uint8) * 255
345
+ walls = cv2.bitwise_and(walls, keep_mask)
346
+ walls = self._thin_line_filter(walls, body_thickness)
347
+ n, labels, stats, _ = cv2.connectedComponentsWithStats(walls, connectivity=8)
348
+ if n > 1:
349
+ areas = stats[1:, cv2.CC_STAT_AREA]
350
+ min_noise = max(20, int(np.median(areas) * 0.0001))
351
+ lut = np.zeros(n, np.uint8)
352
+ lut[1:] = (areas >= min_noise).astype(np.uint8)
353
+ walls = (lut[labels] * 255).astype(np.uint8)
354
+ return walls
355
+
356
+ def _estimate_wall_thickness(self, binary: np.ndarray, fallback=12) -> int:
357
+ h, w = binary.shape
358
+ n_cols = min(200, w)
359
+ col_idx = np.linspace(0, w-1, n_cols, dtype=int)
360
+ runs = []
361
+ max_run = max(2, int(h * 0.05))
362
+ for ci in col_idx:
363
+ col = (binary[:, ci] > 0).astype(np.int8)
364
+ pad = np.concatenate([[0], col, [0]])
365
+ d = np.diff(pad.astype(np.int16))
366
+ s = np.where(d == 1)[0]
367
+ e = np.where(d == -1)[0]
368
+ n = min(len(s), len(e))
369
+ r = (e[:n] - s[:n]).astype(int)
370
+ runs.extend(r[(r >= 2) & (r <= max_run)].tolist())
371
+ if runs:
372
+ return int(np.median(runs))
373
+ return fallback
374
+
375
+ def _thin_line_filter(self, walls: np.ndarray, min_thickness: int) -> np.ndarray:
376
+ dist = cv2.distanceTransform(walls, cv2.DIST_L2, 5)
377
+ thick_mask = dist >= (min_thickness / 2)
378
+ n, labels, _, _ = cv2.connectedComponentsWithStats(walls, connectivity=8)
379
+ if n <= 1:
380
+ return walls
381
+ thick_labels = labels[thick_mask]
382
+ if len(thick_labels) == 0:
383
+ return np.zeros_like(walls)
384
+ has_thick = np.zeros(n, dtype=bool)
385
+ has_thick[thick_labels] = True
386
+ lut = has_thick.astype(np.uint8) * 255
387
+ lut[0] = 0
388
+ return lut[labels]
389
+
390
+ # ── Stage 5b: Remove fixtures ─────────────────────────────────────────────
391
+ def _remove_fixtures(self, walls: np.ndarray) -> np.ndarray:
392
+ h, w = walls.shape
393
+ n, labels, stats, centroids = cv2.connectedComponentsWithStats(
394
+ walls, connectivity=8)
395
+ if n <= 1:
396
+ return walls
397
+ bw = stats[1:, cv2.CC_STAT_WIDTH].astype(np.float32)
398
+ bh = stats[1:, cv2.CC_STAT_HEIGHT].astype(np.float32)
399
+ ar = stats[1:, cv2.CC_STAT_AREA].astype(np.float32)
400
+ cx = np.round(centroids[1:, 0]).astype(np.int32)
401
+ cy = np.round(centroids[1:, 1]).astype(np.int32)
402
+ maxs = np.maximum(bw, bh)
403
+ mins = np.minimum(bw, bh)
404
+ asp = maxs / (mins + 1e-6)
405
+ cand = ((bw < self.FIXTURE_MAX_BLOB_DIM) & (bh < self.FIXTURE_MAX_BLOB_DIM)
406
+ & (ar < self.FIXTURE_MAX_AREA) & (asp <= self.FIXTURE_MAX_ASPECT))
407
+ ci = np.where(cand)[0]
408
+ if len(ci) == 0:
409
+ return walls
410
+ heatmap = np.zeros((h, w), dtype=np.float32)
411
+ r_heat = int(self.FIXTURE_DENSITY_RADIUS)
412
+ for px, py in zip(cx[ci].tolist(), cy[ci].tolist()):
413
+ cv2.circle(heatmap, (px, py), r_heat, 1.0, -1)
414
+ blur_k = max(3, (r_heat // 2) | 1)
415
+ density = cv2.GaussianBlur(heatmap, (blur_k*4+1, blur_k*4+1), blur_k)
416
+ d_max = float(density.max())
417
+ if d_max > 0:
418
+ density /= d_max
419
+ zone = (density >= self.FIXTURE_DENSITY_THRESHOLD).astype(np.uint8) * 255
420
+ n_z, z_labels, z_stats, _ = cv2.connectedComponentsWithStats(zone)
421
+ clean = np.zeros_like(zone)
422
+ if n_z > 1:
423
+ za = z_stats[1:, cv2.CC_STAT_AREA]
424
+ kz = np.where(za >= self.FIXTURE_MIN_ZONE_AREA)[0] + 1
425
+ if len(kz):
426
+ lut = np.zeros(n_z, np.uint8)
427
+ lut[kz] = 255
428
+ clean = lut[z_labels]
429
+ zone = clean
430
+ valid = (cy[ci].clip(0,h-1) >= 0) & (cx[ci].clip(0,w-1) >= 0)
431
+ in_zone = valid & (zone[cy[ci].clip(0,h-1), cx[ci].clip(0,w-1)] > 0)
432
+ erase_ids = ci[in_zone] + 1
433
+ result = walls.copy()
434
+ if len(erase_ids):
435
+ lut = np.zeros(n, np.uint8)
436
+ lut[erase_ids] = 1
437
+ result[(lut[labels]).astype(bool)] = 0
438
+ return result
439
+
440
+ # ── Stage 5c: Calibrate + thin-line removal ────────────────────────────────
441
+ def _calibrate_wall(self, mask: np.ndarray) -> WallCalibration:
442
+ cal = WallCalibration()
443
+ h, w = mask.shape
444
+ n_cols = min(200, w)
445
+ col_idx = np.linspace(0, w-1, n_cols, dtype=int)
446
+ runs = []
447
+ max_run = max(2, int(h * 0.05))
448
+ for ci in col_idx:
449
+ col = (mask[:, ci] > 0).astype(np.int8)
450
+ pad = np.concatenate([[0], col, [0]])
451
+ d = np.diff(pad.astype(np.int16))
452
+ s = np.where(d == 1)[0]
453
+ e = np.where(d == -1)[0]
454
+ n_ = min(len(s), len(e))
455
+ r = (e[:n_] - s[:n_]).astype(int)
456
+ runs.extend(r[(r >= 1) & (r <= max_run)].tolist())
457
+ if runs:
458
+ arr = np.array(runs, np.int32)
459
+ hist = np.bincount(np.clip(arr, 0, 200))
460
+ cal.stroke_width = max(2, int(np.argmax(hist[1:])) + 1)
461
+ cal.min_component_dim = max(15, cal.stroke_width * 10)
462
+ cal.min_component_area = max(30, cal.stroke_width * cal.min_component_dim // 2)
463
+
464
+ gap_sizes = []
465
+ row_step = max(3, h // 200)
466
+ col_step = max(3, w // 200)
467
+ for row in range(5, h-5, row_step):
468
+ rd = (mask[row, :] > 0).astype(np.int8)
469
+ pad = np.concatenate([[0], rd, [0]])
470
+ dif = np.diff(pad.astype(np.int16))
471
+ ends = np.where(dif == -1)[0]
472
+ starts = np.where(dif == 1)[0]
473
+ for e in ends:
474
+ nxt = starts[starts > e]
475
+ if len(nxt):
476
+ g = int(nxt[0] - e)
477
+ if 1 < g < 200:
478
+ gap_sizes.append(g)
479
+ for col in range(5, w-5, col_step):
480
+ cd = (mask[:, col] > 0).astype(np.int8)
481
+ pad = np.concatenate([[0], cd, [0]])
482
+ dif = np.diff(pad.astype(np.int16))
483
+ ends = np.where(dif == -1)[0]
484
+ starts = np.where(dif == 1)[0]
485
+ for e in ends:
486
+ nxt = starts[starts > e]
487
+ if len(nxt):
488
+ g = int(nxt[0] - e)
489
+ if 1 < g < 200:
490
+ gap_sizes.append(g)
491
+
492
+ cal.bridge_min_gap = 2
493
+ if len(gap_sizes) >= 20:
494
+ g = np.array(gap_sizes)
495
+ sm = g[g <= 30]
496
+ if len(sm) >= 10:
497
+ cal.bridge_max_gap = int(np.clip(np.percentile(sm, 75), 4, 20))
498
+ else:
499
+ cal.bridge_max_gap = cal.stroke_width * 4
500
+ door = g[(g > cal.bridge_max_gap) & (g <= 80)]
501
+ if len(door) >= 5:
502
+ raw = int(np.percentile(door, 90))
503
+ else:
504
+ raw = max(35, cal.stroke_width * 12)
505
+ raw = int(np.clip(raw, 25, 80))
506
+ cal.door_gap = raw if raw % 2 == 1 else raw + 1
507
+ cal.max_bridge_thick = cal.stroke_width * 5
508
+ self._wall_thickness = cal.stroke_width
509
+ return cal
510
+
511
+ def _remove_thin_lines_calibrated(self, walls: np.ndarray) -> np.ndarray:
512
+ cal = self._wall_cal
513
+ n, cc, stats, _ = cv2.connectedComponentsWithStats(walls, connectivity=8)
514
+ if n <= 1:
515
+ return walls
516
+ bw = stats[1:, cv2.CC_STAT_WIDTH]
517
+ bh = stats[1:, cv2.CC_STAT_HEIGHT]
518
+ ar = stats[1:, cv2.CC_STAT_AREA]
519
+ mx = np.maximum(bw, bh)
520
+ keep = (mx >= cal.min_component_dim) | (ar >= cal.min_component_area * 3)
521
+ lut = np.zeros(n, np.uint8)
522
+ lut[1:] = keep.astype(np.uint8) * 255
523
+ return lut[cc]
524
+
525
+ # ── Stage 5d: Bridge endpoints ─────────────────────────────────────────────
526
+ def _skel(self, binary: np.ndarray) -> np.ndarray:
527
+ if _SKIMAGE:
528
+ return (_sk_skel(binary > 0) * 255).astype(np.uint8)
529
+ return self._morphological_skeleton(binary)
530
+
531
+ def _morphological_skeleton(self, binary: np.ndarray) -> np.ndarray:
532
+ skel = np.zeros_like(binary)
533
+ img = binary.copy()
534
+ cross = cv2.getStructuringElement(cv2.MORPH_CROSS, (3,3))
535
+ for _ in range(300):
536
+ eroded = cv2.erode(img, cross)
537
+ temp = cv2.subtract(img, cv2.dilate(eroded, cross))
538
+ skel = cv2.bitwise_or(skel, temp)
539
+ img = eroded
540
+ if not cv2.countNonZero(img):
541
+ break
542
+ return skel
543
+
544
+ def _tip_pixels(self, skel: np.ndarray):
545
+ sb = (skel > 0).astype(np.float32)
546
+ nbr = cv2.filter2D(sb, -1, np.ones((3,3), np.float32),
547
+ borderType=cv2.BORDER_CONSTANT)
548
+ return np.where((sb == 1) & (nbr.astype(np.int32) == 2))
549
+
550
+ def _outward_vectors(self, ex, ey, skel, lookahead):
551
+ n = len(ex)
552
+ odx = np.zeros(n, np.float32)
553
+ ody = np.zeros(n, np.float32)
554
+ sy, sx = np.where(skel > 0)
555
+ skel_set = set(zip(sx.tolist(), sy.tolist()))
556
+ D8 = [(-1,0),(1,0),(0,-1),(0,1),(-1,-1),(-1,1),(1,-1),(1,1)]
557
+ for i in range(n):
558
+ ox, oy = int(ex[i]), int(ey[i])
559
+ cx, cy = ox, oy
560
+ px, py = ox, oy
561
+ for _ in range(lookahead):
562
+ moved = False
563
+ for dx, dy in D8:
564
+ nx_, ny_ = cx+dx, cy+dy
565
+ if (nx_, ny_) == (px, py):
566
+ continue
567
+ if (nx_, ny_) in skel_set:
568
+ px, py = cx, cy
569
+ cx, cy = nx_, ny_
570
+ moved = True
571
+ break
572
+ if not moved:
573
+ break
574
+ ix, iy = float(cx-ox), float(cy-oy)
575
+ nr = max(1e-6, np.hypot(ix, iy))
576
+ odx[i], ody[i] = -ix/nr, -iy/nr
577
+ return odx, ody
578
+
579
+ def _bridge_endpoints(self, walls: np.ndarray) -> np.ndarray:
580
+ cal = self._wall_cal
581
+ result = walls.copy()
582
+ h, w = walls.shape
583
+ FCOS = np.cos(np.radians(70.0))
584
+ skel = self._skel(walls)
585
+ ey, ex = self._tip_pixels(skel)
586
+ n_ep = len(ey)
587
+ if n_ep < 2:
588
+ return result
589
+ _, cc_map = cv2.connectedComponents(walls, connectivity=8)
590
+ ep_cc = cc_map[ey, ex]
591
+ lookahead = max(8, cal.stroke_width * 3)
592
+ out_dx, out_dy = self._outward_vectors(ex, ey, skel, lookahead)
593
+ pts = np.stack([ex, ey], axis=1).astype(np.float32)
594
+ if _SCIPY:
595
+ pairs = cKDTree(pts).query_pairs(float(cal.bridge_max_gap), output_type='ndarray')
596
+ ii = pairs[:,0].astype(np.int64)
597
+ jj = pairs[:,1].astype(np.int64)
598
+ else:
599
+ _ii, _jj = np.triu_indices(n_ep, k=1)
600
+ ok = np.hypot(pts[_jj,0]-pts[_ii,0], pts[_jj,1]-pts[_ii,1]) <= cal.bridge_max_gap
601
+ ii = _ii[ok].astype(np.int64)
602
+ jj = _jj[ok].astype(np.int64)
603
+ if len(ii) == 0:
604
+ return result
605
+ dxij = pts[jj,0]-pts[ii,0]
606
+ dyij = pts[jj,1]-pts[ii,1]
607
+ dists = np.hypot(dxij, dyij)
608
+ safe = np.maximum(dists, 1e-6)
609
+ ux, uy = dxij/safe, dyij/safe
610
+ ang = np.degrees(np.arctan2(np.abs(dyij), np.abs(dxij)))
611
+ is_H = ang <= 15.0
612
+ is_V = ang >= 75.0
613
+ g1 = (dists >= cal.bridge_min_gap) & (dists <= cal.bridge_max_gap)
614
+ g2 = is_H | is_V
615
+ g3 = ((out_dx[ii]*ux + out_dy[ii]*uy) >= FCOS) & \
616
+ ((out_dx[jj]*-ux + out_dy[jj]*-uy) >= FCOS)
617
+ g4 = ep_cc[ii] != ep_cc[jj]
618
+ pre_ok = g1 & g2 & g3 & g4
619
+ pre_idx = np.where(pre_ok)[0]
620
+ N_SAMP = 9
621
+ clr = np.ones(len(pre_idx), dtype=bool)
622
+ for k, pidx in enumerate(pre_idx):
623
+ ia, ib = int(ii[pidx]), int(jj[pidx])
624
+ ax, ay = int(ex[ia]), int(ey[ia])
625
+ bx, by = int(ex[ib]), int(ey[ib])
626
+ if is_H[pidx]:
627
+ xs = np.linspace(ax, bx, N_SAMP, np.float32)
628
+ ys = np.full(N_SAMP, ay, np.float32)
629
+ else:
630
+ xs = np.full(N_SAMP, ax, np.float32)
631
+ ys = np.linspace(ay, by, N_SAMP, np.float32)
632
+ sxs = np.clip(np.round(xs[1:-1]).astype(np.int32), 0, w-1)
633
+ sys_ = np.clip(np.round(ys[1:-1]).astype(np.int32), 0, h-1)
634
+ if np.any(walls[sys_, sxs] > 0):
635
+ clr[k] = False
636
+ valid = pre_idx[clr]
637
+ if len(valid) == 0:
638
+ return result
639
+ vi = ii[valid]; vj = jj[valid]
640
+ vd = dists[valid]; vH = is_H[valid]
641
+ order = np.argsort(vd)
642
+ vi, vj, vd, vH = vi[order], vj[order], vd[order], vH[order]
643
+ used = np.zeros(n_ep, dtype=bool)
644
+ for k in range(len(vi)):
645
+ ia, ib = int(vi[k]), int(vj[k])
646
+ if used[ia] or used[ib]:
647
+ continue
648
+ ax, ay = int(ex[ia]), int(ey[ia])
649
+ bx, by = int(ex[ib]), int(ey[ib])
650
+ p1, p2 = ((min(ax,bx),ay),(max(ax,bx),ay)) if vH[k] \
651
+ else ((ax,min(ay,by)),(ax,max(ay,by)))
652
+ cv2.line(result, p1, p2, 255, cal.stroke_width)
653
+ used[ia] = used[ib] = True
654
+ return result
655
+
656
+ # ── Stage 5e: Close door openings ─────────────────────────────────────────
657
+ def _close_door_openings(self, walls: np.ndarray) -> np.ndarray:
658
+ cal = self._wall_cal
659
+ gap = cal.door_gap
660
+
661
+ def _shape_close(mask, kwh, axis, max_thick):
662
+ k = cv2.getStructuringElement(cv2.MORPH_RECT, kwh)
663
+ cls = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k)
664
+ new = cv2.bitwise_and(cls, cv2.bitwise_not(mask))
665
+ if not np.any(new):
666
+ return np.zeros_like(mask)
667
+ n, lbl, stats, _ = cv2.connectedComponentsWithStats(new, connectivity=8)
668
+ if n <= 1:
669
+ return np.zeros_like(mask)
670
+ perp = stats[1:, cv2.CC_STAT_HEIGHT if axis == 'H' else cv2.CC_STAT_WIDTH]
671
+ keep = perp <= max_thick
672
+ lut = np.zeros(n, np.uint8)
673
+ lut[1:] = keep.astype(np.uint8) * 255
674
+ return lut[lbl]
675
+
676
+ add_h = _shape_close(walls, (gap,1), 'H', cal.max_bridge_thick)
677
+ add_v = _shape_close(walls, (1,gap), 'V', cal.max_bridge_thick)
678
+ return cv2.bitwise_or(walls, cv2.bitwise_or(add_h, add_v))
679
+
680
+ # ── Stage 5f: Remove dangling lines ───────────────────────────────────────
681
+ def _remove_dangling(self, walls: np.ndarray) -> np.ndarray:
682
+ stroke = self._wall_cal.stroke_width if self._wall_cal else self._wall_thickness
683
+ connect_radius = max(6, stroke * 3)
684
+ n, cc_map, stats, _ = cv2.connectedComponentsWithStats(walls, connectivity=8)
685
+ if n <= 1:
686
+ return walls
687
+ skel = self._skel(walls)
688
+ tip_y, tip_x = self._tip_pixels(skel)
689
+ tip_cc = cc_map[tip_y, tip_x]
690
+ free_counts = np.zeros(n, np.int32)
691
+ for i in range(len(tip_x)):
692
+ free_counts[tip_cc[i]] += 1
693
+ remove = np.zeros(n, dtype=bool)
694
+ for cc_id in range(1, n):
695
+ if free_counts[cc_id] < 2:
696
+ continue
697
+ bw_ = int(stats[cc_id, cv2.CC_STAT_WIDTH])
698
+ bh_ = int(stats[cc_id, cv2.CC_STAT_HEIGHT])
699
+ if max(bw_, bh_) > stroke * 40:
700
+ continue
701
+ comp = (cc_map == cc_id).astype(np.uint8)
702
+ dcomp = cv2.dilate(comp, cv2.getStructuringElement(
703
+ cv2.MORPH_ELLIPSE, (connect_radius*2+1, connect_radius*2+1)))
704
+ overlap = cv2.bitwise_and(
705
+ dcomp, ((walls > 0) & (cc_map != cc_id)).astype(np.uint8))
706
+ if np.count_nonzero(overlap) == 0:
707
+ remove[cc_id] = True
708
+ lut = np.ones(n, np.uint8); lut[0] = 0; lut[remove] = 0
709
+ return (lut[cc_map] * 255).astype(np.uint8)
710
+
711
+ # ── Stage 5g: Large gap closing ────────────────────────────────────────────
712
+ def _close_large_gaps(self, walls: np.ndarray) -> np.ndarray:
713
+ DOOR_MIN_GAP = 180
714
+ DOOR_MAX_GAP = 320
715
+ ANGLE_TOL_DEG = 12.0
716
+ FCOS = np.cos(np.radians(90.0 - ANGLE_TOL_DEG))
717
+ stroke = self._wall_cal.stroke_width if self._wall_cal else self._wall_thickness
718
+ line_width = max(stroke, 3)
719
+ result = walls.copy()
720
+ h, w = walls.shape
721
+ skel = self._skel(walls)
722
+ tip_y, tip_x = self._tip_pixels(skel)
723
+ n_ep = len(tip_x)
724
+ if n_ep < 2:
725
+ return result
726
+ _, cc_map = cv2.connectedComponents(walls, connectivity=8)
727
+ ep_cc = cc_map[tip_y, tip_x]
728
+ lookahead = max(12, stroke * 4)
729
+ out_dx, out_dy = self._outward_vectors(tip_x, tip_y, skel, lookahead)
730
+ pts = np.stack([tip_x, tip_y], axis=1).astype(np.float32)
731
+ if _SCIPY:
732
+ pairs = cKDTree(pts).query_pairs(float(DOOR_MAX_GAP), output_type='ndarray')
733
+ ii = pairs[:,0].astype(np.int64)
734
+ jj = pairs[:,1].astype(np.int64)
735
+ else:
736
+ _ii, _jj = np.triu_indices(n_ep, k=1)
737
+ ok = np.hypot(pts[_jj,0]-pts[_ii,0], pts[_jj,1]-pts[_ii,1]) <= DOOR_MAX_GAP
738
+ ii = _ii[ok].astype(np.int64)
739
+ jj = _jj[ok].astype(np.int64)
740
+ if len(ii) == 0:
741
+ return result
742
+ dxij = pts[jj,0]-pts[ii,0]
743
+ dyij = pts[jj,1]-pts[ii,1]
744
+ dists = np.hypot(dxij, dyij)
745
+ safe = np.maximum(dists, 1e-6)
746
+ ux, uy = dxij/safe, dyij/safe
747
+ ang = np.degrees(np.arctan2(np.abs(dyij), np.abs(dxij)))
748
+ is_H = ang <= ANGLE_TOL_DEG
749
+ is_V = ang >= (90.0 - ANGLE_TOL_DEG)
750
+ g1 = (dists >= DOOR_MIN_GAP) & (dists <= DOOR_MAX_GAP)
751
+ g2 = is_H | is_V
752
+ g3 = ((out_dx[ii]*ux + out_dy[ii]*uy) >= FCOS) & \
753
+ ((out_dx[jj]*-ux + out_dy[jj]*-uy) >= FCOS)
754
+ g4 = ep_cc[ii] != ep_cc[jj]
755
+ pre_ok = g1 & g2 & g3 & g4
756
+ pre_idx = np.where(pre_ok)[0]
757
+ N_SAMP = 15
758
+ clr = np.ones(len(pre_idx), dtype=bool)
759
+ for k, pidx in enumerate(pre_idx):
760
+ ia, ib = int(ii[pidx]), int(jj[pidx])
761
+ ax, ay = int(tip_x[ia]), int(tip_y[ia])
762
+ bx, by = int(tip_x[ib]), int(tip_y[ib])
763
+ if is_H[pidx]:
764
+ xs = np.linspace(ax, bx, N_SAMP, np.float32)
765
+ ys = np.full(N_SAMP, (ay+by)/2.0, np.float32)
766
+ else:
767
+ xs = np.full(N_SAMP, (ax+bx)/2.0, np.float32)
768
+ ys = np.linspace(ay, by, N_SAMP, np.float32)
769
+ sxs = np.clip(np.round(xs[1:-1]).astype(np.int32), 0, w-1)
770
+ sys_ = np.clip(np.round(ys[1:-1]).astype(np.int32), 0, h-1)
771
+ if np.any(walls[sys_, sxs] > 0):
772
+ clr[k] = False
773
+ valid = pre_idx[clr]
774
+ if len(valid) == 0:
775
+ return result
776
+ vi = ii[valid]; vj = jj[valid]
777
+ vd = dists[valid]; vH = is_H[valid]
778
+ order = np.argsort(vd)
779
+ vi, vj, vd, vH = vi[order], vj[order], vd[order], vH[order]
780
+ used = np.zeros(n_ep, dtype=bool)
781
+ for k in range(len(vi)):
782
+ ia, ib = int(vi[k]), int(vj[k])
783
+ if used[ia] or used[ib]:
784
+ continue
785
+ ax, ay = int(tip_x[ia]), int(tip_y[ia])
786
+ bx, by = int(tip_x[ib]), int(tip_y[ib])
787
+ if vH[k]:
788
+ p1 = (min(ax,bx),(ay+by)//2)
789
+ p2 = (max(ax,bx),(ay+by)//2)
790
+ else:
791
+ p1 = ((ax+bx)//2, min(ay,by))
792
+ p2 = ((ax+bx)//2, max(ay,by))
793
+ cv2.line(result, p1, p2, 255, line_width)
794
+ used[ia] = used[ib] = True
795
+ return result
796
+
797
+ # ── Stage 7: Flood-fill segmentation ─────────────────────────────────────
798
+ def _segment_rooms(self, walls: np.ndarray) -> np.ndarray:
799
+ h, w = walls.shape
800
+ walls = walls.copy()
801
+ walls[:5,:] = 255; walls[-5:,:] = 255
802
+ walls[:,:5] = 255; walls[:,-5:] = 255
803
+ filled = walls.copy()
804
+ mask = np.zeros((h+2, w+2), np.uint8)
805
+ for sx, sy in [(0,0),(w-1,0),(0,h-1),(w-1,h-1),
806
+ (w//2,0),(w//2,h-1),(0,h//2),(w-1,h//2)]:
807
+ if filled[sy, sx] == 0:
808
+ cv2.floodFill(filled, mask, (sx, sy), 255)
809
+ rooms = cv2.bitwise_not(filled)
810
+ rooms = cv2.bitwise_and(rooms, cv2.bitwise_not(walls))
811
+ rooms = cv2.morphologyEx(rooms, cv2.MORPH_OPEN, np.ones((2,2), np.uint8))
812
+ return rooms
813
+
814
+ # ── Stage 8: Filter room regions ─────────────────────────────────────────
815
+ def _filter_rooms(self, rooms_mask, img_shape):
816
+ h, w = img_shape[:2]
817
+ img_area = float(h * w)
818
+ min_area = img_area * self.MIN_ROOM_AREA_FRAC
819
+ max_area = img_area * self.MAX_ROOM_AREA_FRAC
820
+ min_dim = w * self.MIN_ROOM_DIM_FRAC
821
+ margin = max(5.0, w * self.BORDER_MARGIN_FRAC)
822
+ contours, _ = cv2.findContours(rooms_mask, cv2.RETR_EXTERNAL,
823
+ cv2.CHAIN_APPROX_SIMPLE)
824
+ if not contours:
825
+ return np.zeros_like(rooms_mask), []
826
+ valid_mask = np.zeros_like(rooms_mask)
827
+ valid_rooms = []
828
+ for cnt in contours:
829
+ area = cv2.contourArea(cnt)
830
+ if not (min_area <= area <= max_area):
831
+ continue
832
+ bx, by, bw, bh = cv2.boundingRect(cnt)
833
+ if bx < margin or by < margin or bx+bw > w-margin or by+bh > h-margin:
834
+ continue
835
+ if not (bw >= min_dim or bh >= min_dim):
836
+ continue
837
+ asp = max(bw,bh) / (min(bw,bh) + 1e-6)
838
+ if asp > self.MAX_ASPECT_RATIO:
839
+ continue
840
+ if (area / (bw*bh + 1e-6)) < self.MIN_EXTENT:
841
+ continue
842
+ hull = cv2.convexHull(cnt)
843
+ ha = cv2.contourArea(hull)
844
+ if ha > 0 and (area / ha) < self.MIN_SOLIDITY:
845
+ continue
846
+ cv2.drawContours(valid_mask, [cnt], -1, 255, -1)
847
+ valid_rooms.append(cnt)
848
+ return valid_mask, valid_rooms
849
+
850
+ # ── Wand: click-to-segment ─────────────────────────────────────────────────
851
+ def wand_segment(self, walls: np.ndarray, click_x: int, click_y: int,
852
+ existing_rooms: List[Dict]) -> Optional[Dict]:
853
+ """
854
+ Flood-fill from click point β†’ return new room dict or None.
855
+ """
856
+ h, w = walls.shape
857
+ if not (0 <= click_x < w and 0 <= click_y < h):
858
+ return None
859
+ if walls[click_y, click_x] > 0:
860
+ return None # clicked on a wall
861
+
862
+ # Build room-candidate mask from flood-fill
863
+ tmp = walls.copy()
864
+ tmp[:5,:] = 255; tmp[-5:,:] = 255
865
+ tmp[:,:5] = 255; tmp[:,-5:] = 255
866
+ filled = tmp.copy()
867
+ mask = np.zeros((h+2, w+2), np.uint8)
868
+ # flood exterior
869
+ for sx, sy in [(0,0),(w-1,0),(0,h-1),(w-1,h-1),
870
+ (w//2,0),(w//2,h-1),(0,h//2),(w-1,h//2)]:
871
+ if filled[sy, sx] == 0:
872
+ cv2.floodFill(filled, mask, (sx, sy), 255)
873
+ rooms = cv2.bitwise_not(filled)
874
+ rooms = cv2.bitwise_and(rooms, cv2.bitwise_not(tmp))
875
+
876
+ # Flood-fill from click to isolate this room
877
+ if rooms[click_y, click_x] == 0:
878
+ return None
879
+
880
+ room_mask = np.zeros((h, w), np.uint8)
881
+ ff_mask = rooms.copy()
882
+ fill_mask = np.zeros((h+2, w+2), np.uint8)
883
+ cv2.floodFill(ff_mask, fill_mask, (click_x, click_y), 128)
884
+ room_mask[ff_mask == 128] = 255
885
+
886
+ area = float(np.count_nonzero(room_mask))
887
+ if area < 100:
888
+ return None
889
+
890
+ contours, _ = cv2.findContours(room_mask, cv2.RETR_EXTERNAL,
891
+ cv2.CHAIN_APPROX_SIMPLE)
892
+ if not contours:
893
+ return None
894
+ cnt = max(contours, key=cv2.contourArea)
895
+ bx, by, bw, bh = cv2.boundingRect(cnt)
896
+ M = cv2.moments(cnt)
897
+ cx = int(M["m10"]/M["m00"]) if M["m00"] else bx+bw//2
898
+ cy = int(M["m01"]/M["m00"]) if M["m00"] else by+bh//2
899
+
900
+ flat_seg = cnt[:,0,:].tolist()
901
+ flat_seg = [v for pt in flat_seg for v in pt]
902
+
903
+ new_id = max((r["id"] for r in existing_rooms), default=0) + 1
904
+ return {
905
+ "id" : new_id,
906
+ "label" : f"Room {new_id}",
907
+ "segmentation": [flat_seg],
908
+ "area" : area,
909
+ "bbox" : [bx, by, bw, bh],
910
+ "centroid" : [cx, cy],
911
+ "confidence" : 0.90,
912
+ "isWand" : True,
913
+ }