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

Update wall_pipeline.py

Browse files
Files changed (1) hide show
  1. wall_pipeline.py +900 -763
wall_pipeline.py CHANGED
@@ -1,46 +1,345 @@
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
@@ -51,863 +350,701 @@ class WallCalibration:
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
- }
 
1
  """
2
+ Wall Extraction Pipeline β€” GPU-Maximised Edition
3
+ ====================================================
4
+ Every bottleneck stage has a GPU fast-path and a CPU fallback.
5
+
6
+ GPU acceleration layers (in order of priority):
7
+ 1. OpenCV CUDA (cv2.cuda_*) β€” morphology, threshold, Gaussian, Canny, Hough
8
+ 2. CuPy β€” NumPy-level array math (chroma, gap analysis, RLE)
9
+ 3. PyTorch CUDA β€” SAM predictor, EasyOCR backbone
10
+ 4. CPU NumPy / OpenCV β€” automatic fallback when GPU unavailable
11
+
12
+ GPU capability matrix:
13
+ Stage CUDA-OpenCV CuPy Torch
14
+ ─────────────────────────────────────────────────────
15
+ Color erase β€” βœ“ β€”
16
+ Wall extract (morph) βœ“ β€” β€”
17
+ Thin-line removal βœ“ (CC fast) β€” β€”
18
+ Skeletonise β€” βœ“ β€”
19
+ Gap analysis / calibrate β€” βœ“ β€”
20
+ Fixture heatmap βœ“ (GaussBlur) βœ“ β€”
21
+ Segment Anything (SAM) β€” β€” βœ“
22
+ EasyOCR β€” β€” βœ“
23
+ Room flood-fill βœ“ β€” β€”
24
  """
25
  from __future__ import annotations
26
 
27
+ import os
28
+ import time
29
+ import warnings
30
  from dataclasses import dataclass
31
+ from typing import Any, Dict, List, Optional, Tuple
32
+
33
+ import cv2
34
+ import numpy as np
35
+
36
+ warnings.filterwarnings("ignore", category=UserWarning)
37
+
38
+ # ══════════════════════════════════════════════════════════════════════════════
39
+ # GPU capability detection
40
+ # ══════════════════════════════════════════════════════════════════════════════
41
+
42
+ # ── PyTorch / CUDA ────────────────────────────────────────────────────────────
43
+ try:
44
+ import torch
45
+ _TORCH = True
46
+ _TORCH_CUDA = torch.cuda.is_available()
47
+ _DEVICE = torch.device("cuda" if _TORCH_CUDA else "cpu")
48
+ if _TORCH_CUDA:
49
+ print(f"[GPU] PyTorch CUDA OK device={torch.cuda.get_device_name(0)}")
50
+ else:
51
+ print("[GPU] PyTorch: CUDA not found β€” CPU tensors")
52
+ except ImportError:
53
+ _TORCH = _TORCH_CUDA = False
54
+ _DEVICE = None
55
+ print("[GPU] PyTorch not installed")
56
 
57
+ # ── CuPy ─────────────────────────────────────────────────────────────────────
58
  try:
59
  import cupy as cp
60
+ import cupyx.scipy.ndimage as cpnd
61
+ _CUPY = True
62
+ print(f"[GPU] CuPy OK version={cp.__version__}")
63
  except ImportError:
64
+ cp = np # type: ignore[assignment]
65
+ cpnd = None # type: ignore[assignment]
66
+ _CUPY = False
67
+ print("[GPU] CuPy not installed β€” NumPy fallback")
68
+
69
+ # ── OpenCV CUDA ───────────────────────────────────────────────────────────────
70
+ _CV_CUDA = False
71
+ try:
72
+ _CV_CUDA = cv2.cuda.getCudaEnabledDeviceCount() > 0
73
+ print(f"[GPU] OpenCV CUDA {'OK' if _CV_CUDA else 'NO'}"
74
+ f" devices={cv2.cuda.getCudaEnabledDeviceCount()}")
75
+ except AttributeError:
76
+ print("[GPU] OpenCV CUDA module absent")
77
 
78
+ # ── scikit-image skeleton ─────────────────────────────────────────────────────
79
  try:
80
  from skimage.morphology import skeletonize as _sk_skel
81
  _SKIMAGE = True
82
  except ImportError:
83
  _SKIMAGE = False
84
 
85
+ # ── scipy KD-tree ─────────────────────────────────────────────────────────────
86
  try:
87
  from scipy.spatial import cKDTree
88
  _SCIPY = True
89
  except ImportError:
90
  _SCIPY = False
91
 
92
+ print(f"[GPU] Summary: PyTorchCUDA={_TORCH_CUDA} CuPy={_CUPY} OpenCV-CUDA={_CV_CUDA}")
93
 
94
+
95
+ # ══════════════════════════════════════════════════════════════════════════════
96
+ # GPU shim helpers
97
+ # ══════════════════════════════════════════════════════════════════════════════
98
  def _to_gpu(arr: np.ndarray):
99
+ return cp.asarray(arr) if _CUPY else arr
100
 
101
  def _to_cpu(arr) -> np.ndarray:
102
+ return cp.asnumpy(arr) if (_CUPY and hasattr(arr, 'get')) else np.asarray(arr)
103
+
104
+
105
+ # ══════════════════════════════════════════════════════════════════════════════
106
+ # CUDA-accelerated OpenCV ops
107
+ # ══════════════════════════════════════════════════════════════════════════════
108
+ def _cuda_morphology(src: np.ndarray, op: int, kernel: np.ndarray) -> np.ndarray:
109
+ if not _CV_CUDA:
110
+ return cv2.morphologyEx(src, op, kernel)
111
+ try:
112
+ g = cv2.cuda_GpuMat(); g.upload(src)
113
+ flt = cv2.cuda.createMorphologyFilter(op, cv2.CV_8UC1, kernel)
114
+ out = flt.apply(g)
115
+ return out.download()
116
+ except Exception:
117
+ return cv2.morphologyEx(src, op, kernel)
118
+
119
+
120
+ def _cuda_threshold(src: np.ndarray, thr: float, maxval: float,
121
+ thtype: int) -> Tuple[float, np.ndarray]:
122
+ if not _CV_CUDA:
123
+ return cv2.threshold(src, thr, maxval, thtype)
124
+ try:
125
+ g = cv2.cuda_GpuMat(); g.upload(src)
126
+ retval, gd = cv2.cuda.threshold(g, thr, maxval, thtype)
127
+ return retval, gd.download()
128
+ except Exception:
129
+ return cv2.threshold(src, thr, maxval, thtype)
130
+
131
+
132
+ def _cuda_gaussian(src: np.ndarray, ksize: Tuple[int,int], sigma: float) -> np.ndarray:
133
+ if not _CV_CUDA:
134
+ return cv2.GaussianBlur(src, ksize, sigma)
135
+ try:
136
+ dtype = cv2.CV_8UC1 if src.ndim == 2 else cv2.CV_8UC3
137
+ g = cv2.cuda_GpuMat(); g.upload(src)
138
+ flt = cv2.cuda.createGaussianFilter(dtype, dtype, ksize, sigma)
139
+ return flt.apply(g).download()
140
+ except Exception:
141
+ return cv2.GaussianBlur(src, ksize, sigma)
142
+
143
+
144
+ def _cuda_canny(src: np.ndarray, lo: float, hi: float) -> np.ndarray:
145
+ if not _CV_CUDA:
146
+ return cv2.Canny(src, lo, hi)
147
+ try:
148
+ g = cv2.cuda_GpuMat(); g.upload(src)
149
+ det = cv2.cuda.createCannyEdgeDetector(lo, hi)
150
+ return det.detect(g).download()
151
+ except Exception:
152
+ return cv2.Canny(src, lo, hi)
153
+
154
+
155
+ def _cuda_dilate(src: np.ndarray, kernel: np.ndarray) -> np.ndarray:
156
+ if not _CV_CUDA:
157
+ return cv2.dilate(src, kernel)
158
+ try:
159
+ g = cv2.cuda_GpuMat(); g.upload(src)
160
+ flt = cv2.cuda.createMorphologyFilter(cv2.MORPH_DILATE, cv2.CV_8UC1, kernel)
161
+ return flt.apply(g).download()
162
+ except Exception:
163
+ return cv2.dilate(src, kernel)
164
+
165
+
166
+ # ══════════════════════════════════════════════════════════════════════════════
167
+ # CuPy-accelerated array ops
168
+ # ══════════════════════════════════════════════════════════════════════════════
169
+ def _cupy_chroma_erase(img: np.ndarray) -> np.ndarray:
170
+ """Remove coloured annotations entirely on GPU."""
171
+ if not _CUPY:
172
+ b = img[:,:,0].astype(np.int32); g = img[:,:,1].astype(np.int32)
173
+ r = img[:,:,2].astype(np.int32)
174
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY).astype(np.int32)
175
+ chroma = np.maximum(np.maximum(r,g),b) - np.minimum(np.minimum(r,g),b)
176
+ mask = (chroma > 15) & (gray < 240)
177
+ result = img.copy(); result[mask] = (255,255,255)
178
+ return result
179
+ # GPU path
180
+ g_img = cp.asarray(img, dtype=cp.int32)
181
+ b_,g_,r_ = g_img[:,:,0], g_img[:,:,1], g_img[:,:,2]
182
+ gray_ = (0.114*b_ + 0.587*g_ + 0.299*r_)
183
+ chroma = cp.maximum(cp.maximum(r_,g_),b_) - cp.minimum(cp.minimum(r_,g_),b_)
184
+ mask = (chroma > 15) & (gray_ < 240)
185
+ result = cp.asarray(img.copy())
186
+ result[mask] = cp.array([255,255,255], dtype=cp.uint8)
187
+ return cp.asnumpy(result)
188
+
189
+
190
+ def _cupy_gap_analysis(mask: np.ndarray) -> List[int]:
191
+ """Scan H+V gap lengths on GPU (CuPy batch diff); CPU fallback."""
192
+ h, w = mask.shape
193
+ row_step = max(3, h//200)
194
+ col_step = max(3, w//200)
195
+ gaps: List[int] = []
196
+
197
+ def _harvest(diff_row: np.ndarray):
198
+ ends_ = np.where(diff_row == -1)[0]
199
+ starts_ = np.where(diff_row == 1)[0]
200
+ for e in ends_:
201
+ nxt = starts_[starts_ > e]
202
+ if len(nxt):
203
+ g = int(nxt[0] - e)
204
+ if 1 < g < 200:
205
+ gaps.append(g)
206
+
207
+ if not _CUPY:
208
+ for row in range(5, h-5, row_step):
209
+ rd = (mask[row,:] > 0).astype(np.int8)
210
+ _harvest(np.diff(np.concatenate([[0],rd,[0]]).astype(np.int16)))
211
+ for col in range(5, w-5, col_step):
212
+ cd = (mask[:,col] > 0).astype(np.int8)
213
+ _harvest(np.diff(np.concatenate([[0],cd,[0]]).astype(np.int16)))
214
+ return gaps
215
+
216
+ # GPU: batch rows
217
+ rows_np = mask[5:h-5:row_step, :].astype(np.int8)
218
+ g_rows = cp.asarray(rows_np > 0, dtype=cp.int8)
219
+ g_pad = cp.concatenate([cp.zeros((g_rows.shape[0],1),cp.int8),
220
+ g_rows,
221
+ cp.zeros((g_rows.shape[0],1),cp.int8)], axis=1)
222
+ g_diff = cp.diff(g_pad.astype(cp.int16), axis=1)
223
+ for ri in range(g_diff.shape[0]):
224
+ _harvest(cp.asnumpy(g_diff[ri]))
225
+
226
+ # GPU: batch cols
227
+ cols_np = mask[:, 5:w-5:col_step].T.astype(np.int8)
228
+ g_cols = cp.asarray(cols_np > 0, dtype=cp.int8)
229
+ g_pad = cp.concatenate([cp.zeros((g_cols.shape[0],1),cp.int8),
230
+ g_cols,
231
+ cp.zeros((g_cols.shape[0],1),cp.int8)], axis=1)
232
+ g_diff = cp.diff(g_pad.astype(cp.int16), axis=1)
233
+ for ci in range(g_diff.shape[0]):
234
+ _harvest(cp.asnumpy(g_diff[ci]))
235
+ return gaps
236
+
237
+
238
+ def _cupy_rle(mask: np.ndarray) -> Dict[str, Any]:
239
+ """COCO RLE encoding on GPU."""
240
+ h, w = mask.shape
241
+ if not _CUPY:
242
+ flat = mask.flatten(order='F').astype(bool)
243
+ counts: List[int] = []; cur, run = False, 0
244
+ for v in flat:
245
+ if v == cur: run += 1
246
+ else: counts.append(run); run=1; cur=v
247
+ counts.append(run)
248
+ if mask[0,0]: counts.insert(0,0)
249
+ return {"counts": counts, "size": [h, w]}
250
+
251
+ g_flat = cp.asarray(mask, dtype=cp.bool_).flatten(order='F')
252
+ pad = cp.concatenate([cp.array([False]), g_flat, cp.array([False])])
253
+ diffs = cp.diff(pad.astype(cp.int8))
254
+ starts = cp.asnumpy(cp.where(diffs == 1)[0])
255
+ ends = cp.asnumpy(cp.where(diffs == -1)[0])
256
+ counts = []; prev = 0
257
+ for s, e in zip(starts, ends):
258
+ counts.append(int(s - prev))
259
+ counts.append(int(e - s))
260
+ prev = e
261
+ counts.append(int(h*w - prev))
262
+ if mask[0,0]: counts.insert(0,0)
263
+ return {"counts": counts, "size": [h, w]}
264
 
265
 
266
+ # ══════════════════════════════════════════════════════════════════════════════
267
+ # OCR singleton (GPU EasyOCR)
268
+ # ══════════════════════════════════════════════════════════════════════════════
269
+ _ocr_reader = None
270
+
271
+ def get_ocr_reader():
272
+ global _ocr_reader
273
+ if _ocr_reader is None:
274
+ try:
275
+ import easyocr
276
+ gpu_flag = _TORCH_CUDA
277
+ print(f"[OCR] Init EasyOCR gpu={gpu_flag}...")
278
+ _ocr_reader = easyocr.Reader(
279
+ ["en"], gpu=gpu_flag,
280
+ model_storage_directory=".models/ocr",
281
+ download_enabled=True)
282
+ print("[OCR] EasyOCR ready")
283
+ except ImportError:
284
+ print("[OCR] EasyOCR not installed")
285
+ _ocr_reader = None
286
+ return _ocr_reader
287
+
288
+
289
+ # ══════════════════════════════════════════════════════════════════════════════
290
+ # SAM singleton (GPU Torch)
291
+ # ══════════════════════════════════════════════════════════════════════════════
292
+ _sam_predictor = None
293
+
294
+ def get_sam_predictor(checkpoint: str = "") -> Optional[Any]:
295
+ global _sam_predictor
296
+ if _sam_predictor is not None:
297
+ return _sam_predictor
298
+ if not checkpoint or not os.path.isfile(checkpoint):
299
+ checkpoint = _download_sam_checkpoint()
300
+ if not checkpoint or not os.path.isfile(checkpoint):
301
+ print("[SAM] No checkpoint β€” SAM disabled")
302
+ return None
303
+ try:
304
+ from segment_anything import sam_model_registry, SamPredictor
305
+ name = os.path.basename(checkpoint).lower()
306
+ mtype = ("vit_h" if "vit_h" in name else
307
+ "vit_l" if "vit_l" in name else
308
+ "vit_b" if "vit_b" in name else "vit_h")
309
+ dev = "cuda" if _TORCH_CUDA else "cpu"
310
+ print(f"[SAM] Loading {mtype} on {dev}...")
311
+ sam = sam_model_registry[mtype](checkpoint=checkpoint)
312
+ sam.to(device=dev); sam.eval()
313
+ _sam_predictor = SamPredictor(sam)
314
+ print(f"[SAM] Ready on {dev}")
315
+ except Exception as exc:
316
+ print(f"[SAM] Load failed: {exc}")
317
+ _sam_predictor = None
318
+ return _sam_predictor
319
+
320
+
321
+ def _download_sam_checkpoint() -> str:
322
+ dest = os.path.join(".models", "sam", "sam_vit_h_4b8939.pth")
323
+ if os.path.isfile(dest):
324
+ return dest
325
+ try:
326
+ from huggingface_hub import hf_hub_download
327
+ os.makedirs(os.path.dirname(dest), exist_ok=True)
328
+ print("[SAM] Downloading from HF Hub...")
329
+ path = hf_hub_download(
330
+ repo_id="facebook/sam-vit-huge",
331
+ filename="sam_vit_h_4b8939.pth",
332
+ local_dir=os.path.dirname(dest))
333
+ print(f"[SAM] Saved to {path}")
334
+ return path
335
+ except Exception as exc:
336
+ print(f"[SAM] Download failed: {exc}")
337
+ return ""
338
+
339
+
340
+ # ══════════════════════════════════════════════════════════════════════════════
341
+ # Calibration dataclass
342
+ # ══════════════════════════════════════════════════════════════════════════════
343
  @dataclass
344
  class WallCalibration:
345
  stroke_width : int = 3
 
350
  door_gap : int = 41
351
  max_bridge_thick : int = 15
352
 
353
+ def as_dict(self) -> Dict[str, Any]:
354
+ return dict(stroke_width=self.stroke_width,
355
+ min_component_dim=self.min_component_dim,
356
+ min_component_area=self.min_component_area,
357
+ bridge_min_gap=self.bridge_min_gap,
358
+ bridge_max_gap=self.bridge_max_gap,
359
+ door_gap=self.door_gap,
360
+ max_bridge_thick=self.max_bridge_thick)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
 
 
 
 
 
 
 
 
362
 
363
+ # ══════════════════════════════════════════════════════════════════════════════
364
+ # Main Pipeline class
365
+ # ══════════════════════════════════════════════════════════════════════════════
366
  class WallPipeline:
367
+ MIN_ROOM_AREA_FRAC = 0.000004
368
+ MAX_ROOM_AREA_FRAC = 0.08
369
+ MIN_ROOM_DIM_FRAC = 0.01
370
+ BORDER_MARGIN_FRAC = 0.01
371
+ MAX_ASPECT_RATIO = 8.0
372
+ MIN_SOLIDITY = 0.25
373
+ MIN_EXTENT = 0.08
 
 
 
 
 
 
374
  FIXTURE_MAX_BLOB_DIM = 80
375
  FIXTURE_MAX_AREA = 4000
376
  FIXTURE_MAX_ASPECT = 4.0
377
  FIXTURE_DENSITY_RADIUS = 50
378
  FIXTURE_DENSITY_THRESHOLD = 0.35
379
  FIXTURE_MIN_ZONE_AREA = 1500
380
+ SAM_MIN_SCORE = 0.70
381
+ SAM_WALL_THICK_PERCENTILE = 75
382
+ WALL_MIN_HALF_THICKNESS = 3
383
+ SAM_N_NEG_PROMPTS = 20
384
+ SAM_CLOSET_THRESHOLD = 300
385
+ OCR_CONFIDENCE = 0.30
386
+
387
+ def __init__(self, progress_cb=None, sam_checkpoint: str = ""):
388
+ self.progress_cb = progress_cb or (lambda m, p: None)
389
+ self._wall_cal : Optional[WallCalibration] = None
390
+ self._wall_thickness : int = 8
391
+ self.stage_images : Dict[str, np.ndarray] = {}
392
+ self._sam_checkpoint = sam_checkpoint
393
+ self._sam_room_masks : List[Dict] = []
394
 
395
  def _log(self, msg: str, pct: int):
396
+ print(f" [{pct:3d}%] {msg}")
397
  self.progress_cb(msg, pct)
398
 
399
  def _save(self, key: str, img: np.ndarray):
400
  self.stage_images[key] = img.copy()
401
 
402
+ # ──────────────────────────────────────────────────────────────────────────
403
  def run(self, img_bgr: np.ndarray,
404
+ extra_door_lines: List[Tuple[int,int,int,int]] = None,
405
+ use_sam: bool = True,
406
  ) -> Tuple[np.ndarray, np.ndarray, WallCalibration]:
407
+ t0 = time.perf_counter()
408
+ self.stage_images = {}
409
+ self._sam_room_masks = []
410
+
411
+ self._log("Step 1 β€” Title block removal", 4)
412
+ img = self._remove_title_block(img_bgr); self._save("01_title_removed", img)
413
+
414
+ self._log("Step 2 β€” Chroma erase [CuPy GPU]", 10)
415
+ img = _cupy_chroma_erase(img); self._save("02_colors_removed", img)
416
+
417
+ self._log("Step 3 β€” Door arc detection [CUDA Hough]", 17)
418
+ img = self._close_door_arcs(img); self._save("03_door_arcs", img)
419
+
420
+ self._log("Step 4 β€” Wall extraction [CUDA morph]", 26)
421
+ walls = self._extract_walls(img); self._save("04_walls_raw", walls)
422
+
423
+ self._log("Step 5b β€” Fixture removal [CUDA blur]", 34)
424
+ walls = self._remove_fixtures(walls); self._save("05b_no_fixtures", walls)
425
+
426
+ self._log("Step 5c β€” Calibrate [CuPy] + thin-line removal", 41)
 
 
 
 
 
 
427
  self._wall_cal = self._calibrate_wall(walls)
428
  walls = self._remove_thin_lines_calibrated(walls)
429
  self._save("05c_thin_removed", walls)
430
 
431
+ self._log("Step 5d β€” Endpoint bridging", 50)
432
+ walls = self._bridge_endpoints(walls); self._save("05d_bridged", walls)
 
433
 
434
+ self._log("Step 5e β€” Door gap closing [CUDA morph]", 58)
435
+ walls = self._close_door_openings(walls); self._save("05e_doors_closed", walls)
 
436
 
437
+ self._log("Step 5f β€” Dangling line removal", 65)
438
+ walls = self._remove_dangling(walls); self._save("05f_dangling_removed", walls)
 
439
 
440
+ self._log("Step 5g β€” Large door gap sealing", 71)
441
+ walls = self._close_large_gaps(walls); self._save("05g_large_gaps", walls)
 
442
 
 
443
  if extra_door_lines:
444
+ self._log("Manual door seal lines", 74)
445
  lw = max(3, self._wall_cal.stroke_width if self._wall_cal else 3)
446
+ for x1,y1,x2,y2 in extra_door_lines:
447
+ cv2.line(walls,(x1,y1),(x2,y2),255,lw)
448
  self._save("05h_manual_doors", walls)
449
 
450
+ rooms_mask = None
451
+ if use_sam:
452
+ self._log("Step 7 β€” SAM segmentation [Torch GPU]", 78)
453
+ rooms_mask = self._segment_with_sam(img_bgr, walls)
454
 
455
+ if rooms_mask is None:
456
+ self._log("Step 7 β€” Flood-fill segmentation", 80)
457
+ rooms_mask = self._segment_rooms(walls)
458
+ self._save("07_rooms", rooms_mask)
459
+
460
+ self._log("Step 8 β€” Room filtering", 90)
461
+ valid_mask, _ = self._filter_rooms(rooms_mask, img_bgr.shape)
462
  self._save("08_rooms_filtered", valid_mask)
463
 
464
+ self._log(f"Done in {time.perf_counter()-t0:.1f}s", 100)
465
  return walls, valid_mask, self._wall_cal
466
 
467
+ # ══════════════════════════════════════════════════════════════════════════
468
+ # STAGE 1 β€” Title block
469
+ # ══════════════════════════════════════════════════════════════════════════
470
  def _remove_title_block(self, img: np.ndarray) -> np.ndarray:
471
+ h,w = img.shape[:2]
472
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
473
+ edges = _cuda_canny(gray, 50, 150)
474
+ hk = cv2.getStructuringElement(cv2.MORPH_RECT,(w//20,1))
475
+ vk = cv2.getStructuringElement(cv2.MORPH_RECT,(1,h//20))
476
+ hl = _cuda_morphology(edges, cv2.MORPH_OPEN, hk)
477
+ vl = _cuda_morphology(edges, cv2.MORPH_OPEN, vk)
478
+ cr,cb = w,h
479
+ rr = vl[:, int(w*0.7):]
480
+ if np.any(rr):
481
+ vp = np.where(np.sum(rr,axis=0)>h*0.3)[0]
482
+ if len(vp): cr = int(w*0.7)+vp[0]-10
483
+ br = hl[int(h*0.7):,:]
484
+ if np.any(br):
485
+ hp = np.where(np.sum(br,axis=1)>w*0.3)[0]
486
+ if len(hp): cb = int(h*0.7)+hp[0]-10
487
+ return img[:cb,:cr].copy()
488
+
489
+ # ══════════════════════════════════════════════════════════════════════════
490
+ # STAGE 3 β€” Door arcs
491
+ # ══════════════════════════════════════════════════════════════════════════
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
492
  def _close_door_arcs(self, img: np.ndarray) -> np.ndarray:
493
  gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
494
+ h,w = gray.shape
495
  result = img.copy()
496
+ _,binary = cv2.threshold(gray,0,255,cv2.THRESH_BINARY_INV+cv2.THRESH_OTSU)
497
+ binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, np.ones((3,3),np.uint8))
498
+ blurred = _cuda_gaussian(gray,(7,7),1.5)
499
+ raw = cv2.HoughCircles(blurred,cv2.HOUGH_GRADIENT,dp=1.2,minDist=50,
500
+ param1=50,param2=22,minRadius=60,maxRadius=320)
501
+ if raw is None: return result
 
 
 
 
502
  circles = np.round(raw[0]).astype(np.int32)
503
+ for cx,cy,r in circles:
504
+ angles = np.linspace(0,2*np.pi,360,endpoint=False)
505
+ xs = np.clip((cx+r*np.cos(angles)).astype(np.int32),0,w-1)
506
+ ys = np.clip((cy+r*np.sin(angles)).astype(np.int32),0,h-1)
507
+ on_wall = binary[ys,xs]>0
508
+ if not np.any(on_wall): continue
509
+ occ = angles[on_wall]
510
+ span = float(np.degrees(occ[-1]-occ[0]))
511
+ if not (60<=span<=115): continue
512
+ lr = r*0.92
513
+ la = np.linspace(0,2*np.pi,max(60,int(r)),endpoint=False)
514
+ lx = np.clip((cx+lr*np.cos(la)).astype(np.int32),0,w-1)
515
+ ly = np.clip((cy+lr*np.sin(la)).astype(np.int32),0,h-1)
516
+ if float(np.mean(binary[ly,lx]>0))<0.35: continue
 
 
 
 
 
 
517
  diffs = np.diff(occ)
518
+ big = np.where(diffs>np.radians(25))[0]
519
+ if len(big):
520
+ idx = big[np.argmax(diffs[big])]
521
+ start_a,end_a = occ[idx+1],occ[idx]
522
  else:
523
+ start_a,end_a = occ[0],occ[-1]
524
+ ep1=(np.clip(int(round(cx+r*np.cos(start_a))),0,w-1),
525
+ np.clip(int(round(cy+r*np.sin(start_a))),0,h-1))
526
+ ep2=(np.clip(int(round(cx+r*np.cos(end_a))),0,w-1),
527
+ np.clip(int(round(cy+r*np.sin(end_a))),0,h-1))
528
+ cv2.line(result,ep1,ep2,(0,0,0),3)
 
 
 
529
  return result
530
 
531
+ # ══════════════════════════════════════════════════════════════════════════
532
+ # STAGE 4 β€” Wall extraction (CUDA morphology)
533
+ # ══════════════════════════════════════════════════════════════════════════
534
  def _extract_walls(self, img: np.ndarray) -> np.ndarray:
535
  gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
536
+ h,w = gray.shape
537
+ otsu,_ = cv2.threshold(gray,0,255,cv2.THRESH_BINARY_INV+cv2.THRESH_OTSU)
538
+ brt = float(np.mean(gray))
539
+ thr = (max(200,int(otsu*1.1)) if brt>220 else
540
+ max(150,int(otsu*0.9)) if brt<180 else int(otsu))
541
+ _,binary = _cuda_threshold(gray,thr,255,cv2.THRESH_BINARY_INV)
542
+ binary = binary.astype(np.uint8)
543
+ min_line = max(8, int(0.012*w))
544
+ body = self._estimate_wall_thickness(binary)
545
+ body = int(np.clip(body,9,30))
546
+ self._wall_thickness = body
547
+ kh = cv2.getStructuringElement(cv2.MORPH_RECT,(min_line,1))
548
+ kv = cv2.getStructuringElement(cv2.MORPH_RECT,(1,min_line))
549
+ long_h = _cuda_morphology(binary, cv2.MORPH_OPEN, kh)
550
+ long_v = _cuda_morphology(binary, cv2.MORPH_OPEN, kv)
551
+ orig = cv2.bitwise_or(long_h,long_v)
552
+ kbh = cv2.getStructuringElement(cv2.MORPH_RECT,(1,body))
553
+ kbv = cv2.getStructuringElement(cv2.MORPH_RECT,(body,1))
554
+ dh = _cuda_dilate(long_h,kbh); dv = _cuda_dilate(long_v,kbv)
555
+ walls = cv2.bitwise_or(dh,dv)
556
+ coll = cv2.bitwise_and(dh,dv)
557
+ safe = cv2.bitwise_and(coll,orig)
558
+ walls = cv2.bitwise_or(cv2.bitwise_and(walls,cv2.bitwise_not(coll)),safe)
559
+ dist = cv2.distanceTransform(cv2.bitwise_not(orig),cv2.DIST_L2,5)
560
+ keep = (dist<=body/2).astype(np.uint8)*255
561
+ walls = cv2.bitwise_and(walls,keep)
562
+ walls = self._thin_line_filter(walls,body)
563
+ n,labels,stats,_ = cv2.connectedComponentsWithStats(walls,8)
564
+ if n>1:
565
+ areas = stats[1:,cv2.CC_STAT_AREA]
566
+ mn = max(20,int(np.median(areas)*0.0001))
567
+ lut = np.zeros(n,np.uint8); lut[1:]=(areas>=mn).astype(np.uint8)
568
+ walls = (lut[labels]*255).astype(np.uint8)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
569
  return walls
570
 
571
+ def _estimate_wall_thickness(self, binary: np.ndarray, fallback: int=12) -> int:
572
+ h,w = binary.shape
573
+ ci = np.linspace(0,w-1,min(200,w),dtype=int)
574
+ max_run = max(2,int(h*0.05))
575
  runs = []
576
+ for c in ci:
577
+ col = (binary[:,c]>0).astype(np.int8)
578
+ pad = np.concatenate([[0],col,[0]])
579
+ d = np.diff(pad.astype(np.int16))
580
+ s = np.where(d==1)[0]; e = np.where(d==-1)[0]
581
+ n_ = min(len(s),len(e))
582
+ r = (e[:n_]-s[:n_]).astype(int)
583
+ runs.extend(r[(r>=2)&(r<=max_run)].tolist())
584
+ return int(np.median(runs)) if runs else fallback
 
 
 
 
585
 
586
  def _thin_line_filter(self, walls: np.ndarray, min_thickness: int) -> np.ndarray:
587
+ dist = cv2.distanceTransform(walls,cv2.DIST_L2,5)
588
+ thick = dist>=(min_thickness/2)
589
+ n,labels,_,_ = cv2.connectedComponentsWithStats(walls,8)
590
+ if n<=1: return walls
591
+ tl = labels[thick]
592
+ if not len(tl): return np.zeros_like(walls)
593
+ has = np.zeros(n,bool); has[tl]=True
594
+ lut = has.astype(np.uint8)*255; lut[0]=0
 
 
 
 
595
  return lut[labels]
596
 
597
+ # ══════════════════════════════════════════════════════════════════════════
598
+ # STAGE 5b β€” Fixtures (CUDA Gaussian on heatmap)
599
+ # ══════════════════════════════════════════════════════════════════════════
600
  def _remove_fixtures(self, walls: np.ndarray) -> np.ndarray:
601
+ h,w = walls.shape
602
+ n,labels,stats,centroids = cv2.connectedComponentsWithStats(walls,8)
603
+ if n<=1: return walls
604
+ bw = stats[1:,cv2.CC_STAT_WIDTH].astype(np.float32)
605
+ bh = stats[1:,cv2.CC_STAT_HEIGHT].astype(np.float32)
606
+ ar = stats[1:,cv2.CC_STAT_AREA].astype(np.float32)
607
+ cx = np.round(centroids[1:,0]).astype(np.int32)
608
+ cy = np.round(centroids[1:,1]).astype(np.int32)
609
+ asp = np.maximum(bw,bh)/(np.minimum(bw,bh)+1e-6)
610
+ cand= ((bw<self.FIXTURE_MAX_BLOB_DIM)&(bh<self.FIXTURE_MAX_BLOB_DIM)
611
+ &(ar<self.FIXTURE_MAX_AREA)&(asp<=self.FIXTURE_MAX_ASPECT))
612
+ ci = np.where(cand)[0]
613
+ if not len(ci): return walls
614
+ heatmap = np.zeros((h,w),np.float32)
615
+ rh = int(self.FIXTURE_DENSITY_RADIUS)
616
+ for px,py in zip(cx[ci].tolist(),cy[ci].tolist()):
617
+ cv2.circle(heatmap,(px,py),rh,1.0,-1)
618
+ bk = max(3,(rh//2)|1)
619
+ density = _cuda_gaussian(heatmap,(bk*4+1,bk*4+1),float(bk))
620
+ dm = float(density.max())
621
+ if dm>0: density/=dm
622
+ zone = (density>=self.FIXTURE_DENSITY_THRESHOLD).astype(np.uint8)*255
623
+ nz,zlbl,zs,_ = cv2.connectedComponentsWithStats(zone)
 
 
 
 
 
 
624
  clean = np.zeros_like(zone)
625
+ if nz>1:
626
+ za = zs[1:,cv2.CC_STAT_AREA]
627
+ kz = np.where(za>=self.FIXTURE_MIN_ZONE_AREA)[0]+1
628
  if len(kz):
629
+ lut=np.zeros(nz,np.uint8); lut[kz]=255; clean=lut[zlbl]
 
 
630
  zone = clean
631
+ valid = (cy[ci]>=0)&(cy[ci]<h)&(cx[ci]>=0)&(cx[ci]<w)
632
+ in_z = valid&(zone[cy[ci].clip(0,h-1),cx[ci].clip(0,w-1)]>0)
633
+ erase = ci[in_z]+1
634
  result = walls.copy()
635
+ if len(erase):
636
+ lut=np.zeros(n,np.uint8); lut[erase]=1
637
+ result[lut[labels].astype(bool)]=0
 
638
  return result
639
 
640
+ # ══════════════════════════════════════════════════════════════════════════
641
+ # STAGE 5c β€” Calibrate (CuPy gap analysis)
642
+ # ══════════════════════════════════════════════════════════════════════════
643
  def _calibrate_wall(self, mask: np.ndarray) -> WallCalibration:
644
  cal = WallCalibration()
645
+ h,w = mask.shape
646
+ ci = np.linspace(0,w-1,min(200,w),dtype=int)
647
+ mr = max(2,int(h*0.05))
648
  runs = []
649
+ for c in ci:
650
+ col = (mask[:,c]>0).astype(np.int8)
651
+ pad = np.concatenate([[0],col,[0]])
 
652
  d = np.diff(pad.astype(np.int16))
653
+ s = np.where(d==1)[0]; e=np.where(d==-1)[0]
654
+ n_ = min(len(s),len(e))
655
+ r = (e[:n_]-s[:n_]).astype(int)
656
+ runs.extend(r[(r>=1)&(r<=mr)].tolist())
 
657
  if runs:
658
+ arr = np.array(runs,np.int32)
659
+ hist = np.bincount(np.clip(arr,0,200))
660
+ cal.stroke_width = max(2,int(np.argmax(hist[1:]))+1)
661
+ cal.min_component_dim = max(15,cal.stroke_width*10)
662
+ cal.min_component_area = max(30,cal.stroke_width*cal.min_component_dim//2)
663
+ gap_sizes = _cupy_gap_analysis(mask)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
664
  cal.bridge_min_gap = 2
665
+ if len(gap_sizes)>=20:
666
  g = np.array(gap_sizes)
667
+ sm = g[g<=30]
668
+ cal.bridge_max_gap = (int(np.clip(np.percentile(sm,75),4,20))
669
+ if len(sm)>=10 else cal.stroke_width*4)
670
+ door = g[(g>cal.bridge_max_gap)&(g<=80)]
671
+ raw = int(np.percentile(door,90)) if len(door)>=5 else max(35,cal.stroke_width*12)
672
+ raw = int(np.clip(raw,25,80))
673
+ cal.door_gap = raw if raw%2==1 else raw+1
674
+ cal.max_bridge_thick = cal.stroke_width*5
 
 
 
 
 
675
  self._wall_thickness = cal.stroke_width
676
  return cal
677
 
678
  def _remove_thin_lines_calibrated(self, walls: np.ndarray) -> np.ndarray:
679
  cal = self._wall_cal
680
+ n,cc,stats,_ = cv2.connectedComponentsWithStats(walls,8)
681
+ if n<=1: return walls
682
+ mx = np.maximum(stats[1:,cv2.CC_STAT_WIDTH],stats[1:,cv2.CC_STAT_HEIGHT])
683
+ ar = stats[1:,cv2.CC_STAT_AREA]
684
+ keep = (mx>=cal.min_component_dim)|(ar>=cal.min_component_area*3)
685
+ lut = np.zeros(n,np.uint8); lut[1:]=keep.astype(np.uint8)*255
 
 
 
 
686
  return lut[cc]
687
 
688
+ # ══════════════════════════════════════════════════════════════════════════
689
+ # Skeleton helpers (CuPy-accelerated morphological skeleton)
690
+ # ══════════════════════════════════════════════════════════════════════════
691
  def _skel(self, binary: np.ndarray) -> np.ndarray:
692
  if _SKIMAGE:
693
+ return (_sk_skel(binary>0)*255).astype(np.uint8)
694
+ if _CUPY:
695
+ return self._cupy_skel(binary)
696
  return self._morphological_skeleton(binary)
697
 
698
+ def _cupy_skel(self, binary: np.ndarray) -> np.ndarray:
699
+ try:
700
+ g = cp.asarray(binary>0, dtype=cp.uint8)
701
+ sk = cp.zeros_like(g)
702
+ cr = cp.ones((3,3), dtype=cp.uint8)
703
+ for _ in range(300):
704
+ er = cpnd.binary_erosion(g, cr).astype(cp.uint8)
705
+ op = cpnd.binary_dilation(er, cr).astype(cp.uint8)
706
+ t = cp.maximum(g-op, 0)
707
+ sk = cp.maximum(sk, t)
708
+ g = er
709
+ if not int(cp.any(g)): break
710
+ return (cp.asnumpy(sk)*255).astype(np.uint8)
711
+ except Exception:
712
+ return self._morphological_skeleton(binary)
713
+
714
  def _morphological_skeleton(self, binary: np.ndarray) -> np.ndarray:
715
  skel = np.zeros_like(binary)
716
  img = binary.copy()
717
+ cross = cv2.getStructuringElement(cv2.MORPH_CROSS,(3,3))
718
  for _ in range(300):
719
+ er = cv2.erode(img,cross)
720
+ temp = cv2.subtract(img,cv2.dilate(er,cross))
721
+ skel = cv2.bitwise_or(skel,temp)
722
+ img = er
723
+ if not cv2.countNonZero(img): break
 
724
  return skel
725
 
726
  def _tip_pixels(self, skel: np.ndarray):
727
+ sb = (skel>0).astype(np.float32)
728
+ nbr = cv2.filter2D(sb,-1,np.ones((3,3),np.float32),
729
  borderType=cv2.BORDER_CONSTANT)
730
+ return np.where((sb==1)&(nbr.astype(np.int32)==2))
731
 
732
  def _outward_vectors(self, ex, ey, skel, lookahead):
733
  n = len(ex)
734
+ odx = np.zeros(n,np.float32); ody = np.zeros(n,np.float32)
735
+ sy,sx = np.where(skel>0)
736
+ skel_set = set(zip(sx.tolist(),sy.tolist()))
 
737
  D8 = [(-1,0),(1,0),(0,-1),(0,1),(-1,-1),(-1,1),(1,-1),(1,1)]
738
  for i in range(n):
739
+ ox,oy = int(ex[i]),int(ey[i]); cx,cy=ox,oy; px,py=ox,oy
 
 
740
  for _ in range(lookahead):
741
+ moved=False
742
+ for dx,dy in D8:
743
+ nx_,ny_=cx+dx,cy+dy
744
+ if (nx_,ny_)==(px,py): continue
745
+ if (nx_,ny_) in skel_set:
746
+ px,py=cx,cy; cx,cy=nx_,ny_; moved=True; break
747
+ if not moved: break
748
+ ix,iy=float(cx-ox),float(cy-oy)
749
+ nr=max(1e-6,float(np.hypot(ix,iy)))
750
+ odx[i],ody[i]=-ix/nr,-iy/nr
751
+ return odx,ody
752
+
753
+ # ══════════════════════════════════════════════════════════════════════════
754
+ # STAGE 5d β€” Bridge endpoints
755
+ # ══════════════════════════════════════════════════════════════════════════
 
 
756
  def _bridge_endpoints(self, walls: np.ndarray) -> np.ndarray:
757
+ cal = self._wall_cal; result=walls.copy(); h,w=walls.shape
758
+ FCOS = np.cos(np.radians(70.0))
759
+ skel = self._skel(walls); ey,ex=self._tip_pixels(skel); n_ep=len(ey)
760
+ if n_ep<2: return result
761
+ _,cc_map = cv2.connectedComponents(walls,connectivity=8)
762
+ ep_cc = cc_map[ey,ex]
763
+ odx,ody = self._outward_vectors(ex,ey,skel,max(8,cal.stroke_width*3))
764
+ pts = np.stack([ex,ey],axis=1).astype(np.float32)
 
 
 
 
 
 
765
  if _SCIPY:
766
+ pairs=cKDTree(pts).query_pairs(float(cal.bridge_max_gap),output_type='ndarray')
767
+ ii,jj=pairs[:,0].astype(np.int64),pairs[:,1].astype(np.int64)
 
768
  else:
769
+ _ii,_jj=np.triu_indices(n_ep,k=1)
770
+ ok=np.hypot(pts[_jj,0]-pts[_ii,0],pts[_jj,1]-pts[_ii,1])<=cal.bridge_max_gap
771
+ ii,jj=_ii[ok].astype(np.int64),_jj[ok].astype(np.int64)
772
+ if not len(ii): return result
773
+ dxij=pts[jj,0]-pts[ii,0]; dyij=pts[jj,1]-pts[ii,1]
774
+ dists=np.hypot(dxij,dyij); safe=np.maximum(dists,1e-6)
775
+ ux,uy=dxij/safe,dyij/safe
776
+ ang=np.degrees(np.arctan2(np.abs(dyij),np.abs(dxij)))
777
+ is_H=ang<=15.0; is_V=ang>=75.0
778
+ g1=(dists>=cal.bridge_min_gap)&(dists<=cal.bridge_max_gap)
779
+ g2=is_H|is_V
780
+ g3=((odx[ii]*ux+ody[ii]*uy)>=FCOS)&((odx[jj]*-ux+ody[jj]*-uy)>=FCOS)
781
+ g4=ep_cc[ii]!=ep_cc[jj]
782
+ pre=np.where(g1&g2&g3&g4)[0]
783
+ clr=np.ones(len(pre),bool)
784
+ for k,pidx in enumerate(pre):
785
+ ia,ib=int(ii[pidx]),int(jj[pidx])
786
+ ax,ay=int(ex[ia]),int(ey[ia]); bx,by=int(ex[ib]),int(ey[ib])
787
+ if is_H[pidx]: xs=np.linspace(ax,bx,9,np.float32); ys=np.full(9,ay,np.float32)
788
+ else: xs=np.full(9,ax,np.float32); ys=np.linspace(ay,by,9,np.float32)
789
+ sxs=np.clip(np.round(xs[1:-1]).astype(np.int32),0,w-1)
790
+ sys_=np.clip(np.round(ys[1:-1]).astype(np.int32),0,h-1)
791
+ if np.any(walls[sys_,sxs]>0): clr[k]=False
792
+ valid=pre[clr]
793
+ if not len(valid): return result
794
+ vi,vj=ii[valid],jj[valid]; vd,vH=dists[valid],is_H[valid]
795
+ ord_=np.argsort(vd); vi,vj,vd,vH=vi[ord_],vj[ord_],vd[ord_],vH[ord_]
796
+ used=np.zeros(n_ep,bool)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
797
  for k in range(len(vi)):
798
+ ia,ib=int(vi[k]),int(vj[k])
799
+ if used[ia] or used[ib]: continue
800
+ ax,ay=int(ex[ia]),int(ey[ia]); bx,by=int(ex[ib]),int(ey[ib])
801
+ p1,p2=((min(ax,bx),ay),(max(ax,bx),ay)) if vH[k] else ((ax,min(ay,by)),(ax,max(ay,by)))
802
+ cv2.line(result,p1,p2,255,cal.stroke_width)
803
+ used[ia]=used[ib]=True
 
 
 
804
  return result
805
 
806
+ # ══════════════════════════════════════════════════════════════════════════
807
+ # STAGE 5e β€” Door opening close (CUDA morphology)
808
+ # ══════════════════════════════════════════════════════════════════════════
809
  def _close_door_openings(self, walls: np.ndarray) -> np.ndarray:
810
+ cal=self._wall_cal; gap=cal.door_gap
811
+ def _sc(mask,kwh,axis,mt):
812
+ k =cv2.getStructuringElement(cv2.MORPH_RECT,kwh)
813
+ cls=_cuda_morphology(mask,cv2.MORPH_CLOSE,k)
814
+ new=cv2.bitwise_and(cls,cv2.bitwise_not(mask))
815
+ if not np.any(new): return np.zeros_like(mask)
816
+ n_,lbl,stats,_=cv2.connectedComponentsWithStats(new,8)
817
+ if n_<=1: return np.zeros_like(mask)
818
+ perp=stats[1:,cv2.CC_STAT_HEIGHT if axis=='H' else cv2.CC_STAT_WIDTH]
819
+ keep=perp<=mt; lut=np.zeros(n_,np.uint8); lut[1:]=keep.astype(np.uint8)*255
 
 
 
 
 
 
820
  return lut[lbl]
821
+ ah=_sc(walls,(gap,1),'H',cal.max_bridge_thick)
822
+ av=_sc(walls,(1,gap),'V',cal.max_bridge_thick)
823
+ return cv2.bitwise_or(walls,cv2.bitwise_or(ah,av))
824
 
825
+ # ══════════════════════════════════════════════════════════════════════════
826
+ # STAGE 5f β€” Dangling lines
827
+ # ══════════════════════════════════════════════════════════════════════════
 
 
828
  def _remove_dangling(self, walls: np.ndarray) -> np.ndarray:
829
+ stroke=self._wall_cal.stroke_width if self._wall_cal else self._wall_thickness
830
+ cr=max(6,stroke*3)
831
+ n,cc_map,stats,_=cv2.connectedComponentsWithStats(walls,8)
832
+ if n<=1: return walls
833
+ skel=self._skel(walls); ty,tx=self._tip_pixels(skel); tc=cc_map[ty,tx]
834
+ free=np.zeros(n,np.int32)
835
+ for i in range(len(tx)): free[tc[i]]+=1
836
+ remove=np.zeros(n,bool)
837
+ kc=cv2.getStructuringElement(cv2.MORPH_ELLIPSE,(cr*2+1,cr*2+1))
838
+ for cid in range(1,n):
839
+ if free[cid]<2: continue
840
+ if max(int(stats[cid,cv2.CC_STAT_WIDTH]),int(stats[cid,cv2.CC_STAT_HEIGHT]))>stroke*40: continue
841
+ comp=((cc_map==cid).astype(np.uint8))
842
+ dcomp=cv2.dilate(comp,kc)
843
+ ov=cv2.bitwise_and(dcomp,((walls>0)&(cc_map!=cid)).astype(np.uint8))
844
+ if not np.count_nonzero(ov): remove[cid]=True
845
+ lut=np.ones(n,np.uint8); lut[0]=0; lut[remove]=0
846
+ return (lut[cc_map]*255).astype(np.uint8)
847
+
848
+ # ══════════════════════════════════════════════════════════════════════════
849
+ # STAGE 5g β€” Large door gap sealing
850
+ # ═══════════════════════════════════════════════════════════════��══════════
 
 
 
 
 
 
 
 
851
  def _close_large_gaps(self, walls: np.ndarray) -> np.ndarray:
852
+ DMIN,DMAX,ATOL=180,320,12.0
853
+ FCOS=np.cos(np.radians(90-ATOL))
854
+ stroke=self._wall_cal.stroke_width if self._wall_cal else self._wall_thickness
855
+ result=walls.copy(); h,w=walls.shape
856
+ skel=self._skel(walls); ty,tx=self._tip_pixels(skel); n_ep=len(tx)
857
+ if n_ep<2: return result
858
+ _,cc_map=cv2.connectedComponents(walls,connectivity=8); ep_cc=cc_map[ty,tx]
859
+ odx,ody=self._outward_vectors(tx,ty,skel,max(12,stroke*4))
860
+ pts=np.stack([tx,ty],axis=1).astype(np.float32)
 
 
 
 
 
 
 
 
 
861
  if _SCIPY:
862
+ pairs=cKDTree(pts).query_pairs(float(DMAX),output_type='ndarray')
863
+ ii,jj=pairs[:,0].astype(np.int64),pairs[:,1].astype(np.int64)
 
864
  else:
865
+ _ii,_jj=np.triu_indices(n_ep,k=1)
866
+ ok=np.hypot(pts[_jj,0]-pts[_ii,0],pts[_jj,1]-pts[_ii,1])<=DMAX
867
+ ii,jj=_ii[ok].astype(np.int64),_jj[ok].astype(np.int64)
868
+ if not len(ii): return result
869
+ dxij=pts[jj,0]-pts[ii,0]; dyij=pts[jj,1]-pts[ii,1]
870
+ dists=np.hypot(dxij,dyij); safe=np.maximum(dists,1e-6)
871
+ ux,uy=dxij/safe,dyij/safe
872
+ ang=np.degrees(np.arctan2(np.abs(dyij),np.abs(dxij)))
873
+ is_H=ang<=ATOL; is_V=ang>=(90-ATOL)
874
+ g1=(dists>=DMIN)&(dists<=DMAX); g2=is_H|is_V
875
+ g3=((odx[ii]*ux+ody[ii]*uy)>=FCOS)&((odx[jj]*-ux+ody[jj]*-uy)>=FCOS)
876
+ g4=ep_cc[ii]!=ep_cc[jj]
877
+ pre=np.where(g1&g2&g3&g4)[0]
878
+ clr=np.ones(len(pre),bool)
879
+ for k,pidx in enumerate(pre):
880
+ ia,ib=int(ii[pidx]),int(jj[pidx])
881
+ ax,ay=int(tx[ia]),int(ty[ia]); bx,by=int(tx[ib]),int(ty[ib])
882
+ if is_H[pidx]: xs=np.linspace(ax,bx,15,np.float32); ys=np.full(15,(ay+by)/2,np.float32)
883
+ else: xs=np.full(15,(ax+bx)/2,np.float32); ys=np.linspace(ay,by,15,np.float32)
884
+ sxs=np.clip(np.round(xs[1:-1]).astype(np.int32),0,w-1)
885
+ sys_=np.clip(np.round(ys[1:-1]).astype(np.int32),0,h-1)
886
+ if np.any(walls[sys_,sxs]>0): clr[k]=False
887
+ valid=pre[clr]
888
+ if not len(valid): return result
889
+ vi,vj=ii[valid],jj[valid]; vd,vH=dists[valid],is_H[valid]
890
+ ord_=np.argsort(vd); vi,vj,vd,vH=vi[ord_],vj[ord_],vd[ord_],vH[ord_]
891
+ used=np.zeros(n_ep,bool)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
892
  for k in range(len(vi)):
893
+ ia,ib=int(vi[k]),int(vj[k])
894
+ if used[ia] or used[ib]: continue
895
+ ax,ay=int(tx[ia]),int(ty[ia]); bx,by=int(tx[ib]),int(ty[ib])
896
+ if vH[k]: p1=(min(ax,bx),(ay+by)//2); p2=(max(ax,bx),(ay+by)//2)
897
+ else: p1=((ax+bx)//2,min(ay,by)); p2=((ax+bx)//2,max(ay,by))
898
+ cv2.line(result,p1,p2,255,max(stroke,3))
899
+ used[ia]=used[ib]=True
 
 
 
 
 
 
900
  return result
901
 
902
+ # ══════════════════════════════════════════════════════════════════════════
903
+ # STAGE 7a β€” SAM (Torch GPU)
904
+ # ══════════════════════════════════════════════════════════════════════════
905
+ def _segment_with_sam(self, orig_bgr: np.ndarray,
906
+ walls: np.ndarray) -> Optional[np.ndarray]:
907
+ predictor = get_sam_predictor(self._sam_checkpoint)
908
+ if predictor is None: return None
909
+ try:
910
+ import torch
911
+ h,w = walls.shape
912
+ flood = self._segment_rooms(walls)
913
+ n,labels,stats,centroids=cv2.connectedComponentsWithStats(cv2.bitwise_not(walls),8)
914
+ pos_pts=[]
915
+ for i in range(1,n):
916
+ if int(stats[i,cv2.CC_STAT_AREA])<self.SAM_CLOSET_THRESHOLD: continue
917
+ bx,by,bw,bh=(int(stats[i,cv2.CC_STAT_LEFT]),int(stats[i,cv2.CC_STAT_TOP]),
918
+ int(stats[i,cv2.CC_STAT_WIDTH]),int(stats[i,cv2.CC_STAT_HEIGHT]))
919
+ if bx<=5 and by<=5 and bx+bw>=w-5 and by+bh>=h-5: continue
920
+ cx=int(np.clip(centroids[i][0],0,w-1)); cy=int(np.clip(centroids[i][1],0,h-1))
921
+ if walls[cy,cx]>0: continue
922
+ pos_pts.append((cx,cy))
923
+ dist_t=cv2.distanceTransform(walls,cv2.DIST_L2,5)
924
+ skel =self._skel(walls); sv=dist_t[skel>0]
925
+ neg_pts=[]
926
+ if len(sv):
927
+ thr=max(float(np.percentile(sv,self.SAM_WALL_THICK_PERCENTILE)),
928
+ float(self.WALL_MIN_HALF_THICKNESS))
929
+ ys_,xs_=np.where((skel>0)&(dist_t>=thr))
930
+ step_=max(1,len(ys_)//self.SAM_N_NEG_PROMPTS)
931
+ neg_pts=[(int(xs_[i]),int(ys_[i])) for i in range(0,len(ys_),step_)][:self.SAM_N_NEG_PROMPTS]
932
+ if not pos_pts: return None
933
+ rgb=cv2.cvtColor(orig_bgr,cv2.COLOR_BGR2RGB)
934
+ predictor.set_image(rgb)
935
+ na=np.array(neg_pts,np.float32) if neg_pts else None
936
+ nl=np.zeros(len(neg_pts),np.int32)
937
+ dk=cv2.getStructuringElement(cv2.MORPH_ELLIPSE,(5,5))
938
+ sam_mask=np.zeros((h,w),np.uint8)
939
+ for px,py in pos_pts:
940
+ if na is not None:
941
+ pi=np.vstack([np.array([[px,py]],np.float32),na])
942
+ pl=np.concatenate([[1],nl])
943
+ else:
944
+ pi=np.array([[px,py]],np.float32); pl=np.array([1],np.int32)
945
+ with torch.inference_mode():
946
+ masks,scores,_=predictor.predict(point_coords=pi,point_labels=pl,
947
+ multimask_output=True)
948
+ best=int(np.argmax(scores))
949
+ if float(scores[best])<self.SAM_MIN_SCORE: continue
950
+ m=(masks[best]>0).astype(np.uint8)*255
951
+ m=cv2.bitwise_and(m,flood)
952
+ m=cv2.morphologyEx(m,cv2.MORPH_OPEN,dk)
953
+ if np.any(m):
954
+ self._sam_room_masks.append({"mask":m.copy(),"score":float(scores[best])})
955
+ sam_mask=cv2.bitwise_or(sam_mask,m)
956
+ print(f"[SAM] {len(self._sam_room_masks)} room masks accepted")
957
+ return sam_mask if np.any(sam_mask) else None
958
+ except Exception as exc:
959
+ import traceback; print(f"[SAM] Error: {exc}\n{traceback.format_exc()}")
960
+ return None
961
+
962
+ # ══════════════════════════════════════════════════════════════════════════
963
+ # STAGE 7b β€” Flood-fill segmentation
964
+ # ══════════════════════════════════════════════════════════════════════════
965
  def _segment_rooms(self, walls: np.ndarray) -> np.ndarray:
966
+ h,w = walls.shape
967
+ w2 = walls.copy()
968
+ w2[:5,:]=255; w2[-5:,:]=255; w2[:,:5]=255; w2[:,-5:]=255
969
+ filled=w2.copy(); mask=np.zeros((h+2,w+2),np.uint8)
970
+ for sx,sy in [(0,0),(w-1,0),(0,h-1),(w-1,h-1),
971
+ (w//2,0),(w//2,h-1),(0,h//2),(w-1,h//2)]:
972
+ if filled[sy,sx]==0:
973
+ cv2.floodFill(filled,mask,(sx,sy),255)
974
+ rooms=cv2.bitwise_not(filled)
975
+ rooms=cv2.bitwise_and(rooms,cv2.bitwise_not(w2))
976
+ rooms=_cuda_morphology(rooms,cv2.MORPH_OPEN,np.ones((2,2),np.uint8))
 
 
977
  return rooms
978
 
979
+ # ══════════════════════════════════════════════════════════════════════════
980
+ # STAGE 8 β€” Filter rooms
981
+ # ══════════════════════════════════════════════════════════════════════════
982
+ def _filter_rooms(self, rooms_mask: np.ndarray, img_shape: Tuple):
983
+ h,w=img_shape[:2]; ia=float(h*w)
984
+ min_a=ia*self.MIN_ROOM_AREA_FRAC; max_a=ia*self.MAX_ROOM_AREA_FRAC
985
+ min_d=w*self.MIN_ROOM_DIM_FRAC; margin=max(5.0,w*self.BORDER_MARGIN_FRAC)
986
+ conts,_=cv2.findContours(rooms_mask,cv2.RETR_EXTERNAL,cv2.CHAIN_APPROX_SIMPLE)
987
+ if not conts: return np.zeros_like(rooms_mask),[]
988
+ vm=np.zeros_like(rooms_mask); vr=[]
989
+ for cnt in conts:
990
+ area=cv2.contourArea(cnt)
991
+ if not (min_a<=area<=max_a): continue
992
+ bx,by,bw,bh=cv2.boundingRect(cnt)
993
+ if bx<margin or by<margin or bx+bw>w-margin or by+bh>h-margin: continue
994
+ if not (bw>=min_d or bh>=min_d): continue
995
+ if max(bw,bh)/(min(bw,bh)+1e-6)>self.MAX_ASPECT_RATIO: continue
996
+ if (area/(bw*bh+1e-6))<self.MIN_EXTENT: continue
997
+ hull=cv2.convexHull(cnt); ha=cv2.contourArea(hull)
998
+ if ha>0 and (area/ha)<self.MIN_SOLIDITY: continue
999
+ cv2.drawContours(vm,[cnt],-1,255,-1); vr.append(cnt)
1000
+ return vm,vr
1001
+
1002
+ # ══════════════════════════════════════════════════════════════════════════
1003
+ # OCR (GPU EasyOCR)
1004
+ # ══════════════════════════════════════════════════════════════════════════
1005
+ def extract_label(self, img_bgr: np.ndarray, contour: np.ndarray) -> Optional[str]:
1006
+ reader=get_ocr_reader()
1007
+ if reader is None: return None
1008
+ x,y,w,h=cv2.boundingRect(contour); pad=20
1009
+ roi=img_bgr[max(0,y-pad):min(img_bgr.shape[0],y+h+pad),
1010
+ max(0,x-pad):min(img_bgr.shape[1],x+w+pad)]
1011
+ if roi.size==0: return None
1012
+ gray=cv2.cvtColor(roi,cv2.COLOR_BGR2GRAY)
1013
+ clahe=cv2.createCLAHE(clipLimit=2.0,tileGridSize=(8,8))
1014
+ proc=cv2.threshold(clahe.apply(gray),0,255,cv2.THRESH_BINARY+cv2.THRESH_OTSU)[1]
1015
+ rgb=cv2.cvtColor(cv2.medianBlur(proc,3),cv2.COLOR_GRAY2RGB)
1016
+ try:
1017
+ res=reader.readtext(rgb,detail=1,paragraph=False)
1018
+ cands=[(t.strip().upper(),c) for _,t,c in res
1019
+ if c>=self.OCR_CONFIDENCE and len(t.strip())>=2
1020
+ and any(ch.isalpha() for ch in t)]
1021
+ return max(cands,key=lambda x:x[1])[0] if cands else None
1022
+ except Exception: return None
1023
+
1024
+ # ══════════════════════════════════════════════════════════════════════════
1025
+ # Wand click-to-segment
1026
+ # ══════════════════════════════════════════════════════════════════════════
1027
  def wand_segment(self, walls: np.ndarray, click_x: int, click_y: int,
1028
+ existing_rooms: List[Dict]) -> Optional[Dict]:
1029
+ h,w=walls.shape
1030
+ if not (0<=click_x<w and 0<=click_y<h): return None
1031
+ if walls[click_y,click_x]>0: return None
1032
+ rooms=self._segment_rooms(walls)
1033
+ if rooms[click_y,click_x]==0: return None
1034
+ ff=rooms.copy(); fm=np.zeros((h+2,w+2),np.uint8)
1035
+ cv2.floodFill(ff,fm,(click_x,click_y),128)
1036
+ rmask=((ff==128).astype(np.uint8)*255)
1037
+ area=float(np.count_nonzero(rmask))
1038
+ if area<100: return None
1039
+ conts,_=cv2.findContours(rmask,cv2.RETR_EXTERNAL,cv2.CHAIN_APPROX_SIMPLE)
1040
+ if not conts: return None
1041
+ cnt=max(conts,key=cv2.contourArea)
1042
+ bx,by,bw,bh=cv2.boundingRect(cnt)
1043
+ M=cv2.moments(cnt)
1044
+ cx=int(M["m10"]/M["m00"]) if M["m00"] else bx+bw//2
1045
+ cy=int(M["m01"]/M["m00"]) if M["m00"] else by+bh//2
1046
+ seg=cnt[:,0,:].tolist(); seg=[v for pt in seg for v in pt]
1047
+ nid=max((r["id"] for r in existing_rooms),default=0)+1
1048
+ return {"id":nid,"label":f"Room {nid}","segmentation":[seg],
1049
+ "area":area,"bbox":[bx,by,bw,bh],"centroid":[cx,cy],
1050
+ "confidence":0.90,"isWand":True}