Pream912 commited on
Commit
8e22e2a
Β·
verified Β·
1 Parent(s): 5c5fef7

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +507 -733
app.py CHANGED
@@ -1,21 +1,17 @@
1
  """
2
- FloorPlan Analyser β€” Gradio Application
3
- ========================================
4
- Pipeline (mirrors GeometryAgent v5):
5
- 1. Load image
6
- 2. Crop title block
7
- 3. Remove colors (chroma filter)
8
- 4. Extract walls adaptive
9
- 5. User draws door-closing lines (optional, before SAM)
10
- 6. Segment rooms with SAM (HuggingFace hosted)
11
- 7. OCR β†’ validate room labels
12
- 8. Annotate + measure (area in mΒ²)
13
- 9. Export to Excel
14
- Optional:
15
- β€’ Click to select / deselect room
16
- β€’ Remove wrong annotation
17
- β€’ Pan / Zoom (Gradio native)
18
- β€’ Draw lines to close doors on the wall mask
19
  """
20
 
21
  from __future__ import annotations
@@ -30,17 +26,35 @@ import gradio as gr
30
  import openpyxl
31
  from openpyxl.styles import Font, PatternFill, Alignment
32
 
33
- # ─── SAM HuggingFace endpoint ───────────────────────────────────────────────
34
- HF_REPO = "Pream912/sam"
35
- HF_API = f"https://huggingface.co/{HF_REPO}/resolve/main"
36
- # We'll download the checkpoint locally on first use
37
- SAM_CKPT = Path(tempfile.gettempdir()) / "sam_vit_h_4b8939.pth"
38
- SAM_URL = f"{HF_API}/sam_vit_h_4b8939.pth"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
  DPI = 300
41
- SCALE_FACTOR = 100 # 1 px = 1/300 inch Γ— 100 cm scale
42
 
43
- # ─── constants (ported from GeometryAgent) ──────────────────────────────────
44
  MIN_ROOM_AREA_FRAC = 0.000004
45
  MAX_ROOM_AREA_FRAC = 0.08
46
  MIN_ROOM_DIM_FRAC = 0.01
@@ -62,6 +76,61 @@ ROOM_COLORS = [
62
  (176, 224, 230),
63
  ]
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  # ════════════════════════════════════════════════════════════════════════════
66
  # PIPELINE HELPERS
67
  # ════════════════════════════════════════════════════════════════════════════
@@ -69,7 +138,7 @@ ROOM_COLORS = [
69
  def download_sam_if_needed() -> Optional[str]:
70
  if SAM_CKPT.exists():
71
  return str(SAM_CKPT)
72
- print(f"[SAM] Downloading checkpoint from HuggingFace …")
73
  try:
74
  r = requests.get(SAM_URL, stream=True, timeout=300)
75
  r.raise_for_status()
@@ -90,8 +159,8 @@ def remove_title_block(img: np.ndarray) -> np.ndarray:
90
 
91
  h_kern = cv2.getStructuringElement(cv2.MORPH_RECT, (w // 20, 1))
92
  v_kern = cv2.getStructuringElement(cv2.MORPH_RECT, (1, h // 20))
93
- h_lines = cv2.morphologyEx(edges, cv2.MORPH_OPEN, h_kern)
94
- v_lines = cv2.morphologyEx(edges, cv2.MORPH_OPEN, v_kern)
95
 
96
  crop_r, crop_b = w, h
97
 
@@ -130,7 +199,7 @@ def remove_colors(img: np.ndarray) -> np.ndarray:
130
 
131
 
132
  # ════════════════════════════════════════════════════════════════════════════
133
- # WALL CALIBRATION (exact port from GeometryAgent WallCalibration)
134
  # ════════════════════════════════════════════════════════════════════════════
135
 
136
  from dataclasses import dataclass, field
@@ -261,14 +330,14 @@ def _outward_vectors(ex, ey, skel_u8: np.ndarray, lookahead: int):
261
 
262
 
263
  # ════════════════════════════════════════════════════════════════════════════
264
- # ANALYZE IMAGE CHARACTERISTICS (brightness-aware threshold)
265
  # ════════════════════════════════════════════════════════════════════════════
266
 
267
  def analyze_image_characteristics(img: np.ndarray) -> Dict[str, Any]:
268
  gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
269
  brightness = float(np.mean(gray))
270
  contrast = float(np.std(gray))
271
- otsu_thr, _ = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
272
  if brightness > 220:
273
  wall_threshold = max(200, int(otsu_thr * 1.1))
274
  elif brightness < 180:
@@ -280,7 +349,7 @@ def analyze_image_characteristics(img: np.ndarray) -> Dict[str, Any]:
280
 
281
 
282
  # ════════════════════════════════════════════════════════════════════════════
283
- # DOOR ARC DETECTION (exact port from GeometryAgent)
284
  # ════════════════════════════════════════════════════════════════════════════
285
 
286
  def detect_and_close_door_arcs(img: np.ndarray) -> np.ndarray:
@@ -293,9 +362,11 @@ def detect_and_close_door_arcs(img: np.ndarray) -> np.ndarray:
293
  h, w = gray.shape
294
  result = img.copy()
295
 
296
- _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
297
- binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, np.ones((3,3), np.uint8))
298
- blurred = cv2.GaussianBlur(gray, (7,7), 1.5)
 
 
299
 
300
  raw = cv2.HoughCircles(blurred, cv2.HOUGH_GRADIENT, dp=DP, minDist=MIN_DIST,
301
  param1=PARAM1, param2=PARAM2, minRadius=R_MIN, maxRadius=R_MAX)
@@ -303,6 +374,7 @@ def detect_and_close_door_arcs(img: np.ndarray) -> np.ndarray:
303
  return result
304
 
305
  circles = np.round(raw[0]).astype(np.int32)
 
306
 
307
  def sample_ring(cx, cy, r, n=360):
308
  ang = np.linspace(0, 2*np.pi, n, endpoint=False)
@@ -392,7 +464,7 @@ def detect_and_close_door_arcs(img: np.ndarray) -> np.ndarray:
392
 
393
 
394
  # ════════════════════════════════════════════════════════════════════════════
395
- # EXTRACT WALLS ADAPTIVE (exact port β€” brightness-aware + double-line filter)
396
  # ════════════════════════════════════════════════════════════════════════════
397
 
398
  def _estimate_wall_body_thickness(binary: np.ndarray, fallback: int = 12) -> int:
@@ -418,7 +490,7 @@ def _estimate_wall_body_thickness(binary: np.ndarray, fallback: int = 12) -> int
418
 
419
 
420
  def _remove_thin_lines(walls: np.ndarray, min_thickness: int) -> np.ndarray:
421
- dist = cv2.distanceTransform(walls, cv2.DIST_L2, 5)
422
  thick_mask = dist >= (min_thickness / 2)
423
  n_lbl, labels, _, _ = cv2.connectedComponentsWithStats(walls, connectivity=8)
424
  if n_lbl <= 1: return walls
@@ -500,14 +572,14 @@ def extract_walls_adaptive(img_clean: np.ndarray,
500
  h, w = img_clean.shape[:2]
501
  gray = cv2.cvtColor(img_clean, cv2.COLOR_BGR2GRAY)
502
 
503
- # brightness-aware threshold (from analyze_image_characteristics)
504
  if img_stats:
505
  wall_threshold = img_stats["wall_threshold"]
506
  else:
507
- otsu_t, _ = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV+cv2.THRESH_OTSU)
508
  wall_threshold = int(otsu_t)
509
 
510
- _, binary = cv2.threshold(gray, wall_threshold, 255, cv2.THRESH_BINARY_INV)
 
511
 
512
  min_line_len = max(8, int(0.012 * w))
513
  body_thickness = _estimate_wall_body_thickness(binary, fallback=12)
@@ -515,14 +587,14 @@ def extract_walls_adaptive(img_clean: np.ndarray,
515
 
516
  k_h = cv2.getStructuringElement(cv2.MORPH_RECT, (min_line_len, 1))
517
  k_v = cv2.getStructuringElement(cv2.MORPH_RECT, (1, min_line_len))
518
- long_h = cv2.morphologyEx(binary, cv2.MORPH_OPEN, k_h)
519
- long_v = cv2.morphologyEx(binary, cv2.MORPH_OPEN, k_v)
520
  orig_walls = cv2.bitwise_or(long_h, long_v)
521
 
522
  k_bh = cv2.getStructuringElement(cv2.MORPH_RECT, (1, body_thickness))
523
  k_bv = cv2.getStructuringElement(cv2.MORPH_RECT, (body_thickness, 1))
524
- dil_h = cv2.dilate(long_h, k_bh)
525
- dil_v = cv2.dilate(long_v, k_bv)
526
  walls = cv2.bitwise_or(dil_h, dil_v)
527
 
528
  collision = cv2.bitwise_and(dil_h, dil_v)
@@ -542,13 +614,12 @@ def extract_walls_adaptive(img_clean: np.ndarray,
542
  keep_lut[1:] = (areas >= min_n).astype(np.uint8)
543
  walls = (keep_lut[labels] * 255).astype(np.uint8)
544
 
545
- walls = _filter_double_lines_and_thick(walls) # ← was missing
546
-
547
  return walls, body_thickness
548
 
549
 
550
  # ════════════════════════════════════════════════════════════════════════════
551
- # REMOVE FIXTURE SYMBOLS (exact port from GeometryAgent)
552
  # ════════════════════════════════════════════════════════════════════════════
553
 
554
  FIXTURE_MAX_BLOB=80; FIXTURE_MAX_AREA=4000; FIXTURE_MAX_ASP=4.0
@@ -574,7 +645,10 @@ def remove_fixture_symbols(walls: np.ndarray) -> np.ndarray:
574
  for x2,y2 in zip(ccx.tolist(), ccy.tolist()):
575
  cv2.circle(heatmap,(x2,y2),int(FIXTURE_DENSITY_R),1.0,-1)
576
  bk=max(3,(int(FIXTURE_DENSITY_R)//2)|1)
577
- density=cv2.GaussianBlur(heatmap,(bk*4+1,bk*4+1),bk)
 
 
 
578
  dm=float(density.max())
579
  if dm>0: density/=dm
580
  zone=(density>=FIXTURE_DENSITY_THR).astype(np.uint8)*255
@@ -597,9 +671,6 @@ def remove_fixture_symbols(walls: np.ndarray) -> np.ndarray:
597
 
598
  # ════════════════════════════════════════════════════════════════════════════
599
  # WALL RECONSTRUCTION β€” 3-stage calibrated pipeline
600
- # [5c] remove_thin_lines_calibrated
601
- # [5d] bridge_wall_endpoints_v2
602
- # [5e] close_door_openings_v2
603
  # ════════════════════════════════════════════════════════════════════════════
604
 
605
  def _remove_thin_lines_calibrated(walls: np.ndarray, cal: WallCalibration) -> np.ndarray:
@@ -614,6 +685,10 @@ def _remove_thin_lines_calibrated(walls: np.ndarray, cal: WallCalibration) -> np
614
 
615
  def _bridge_wall_endpoints_v2(walls: np.ndarray, cal: WallCalibration,
616
  angle_tol: float = 15.0) -> np.ndarray:
 
 
 
 
617
  try:
618
  from scipy.spatial import cKDTree as _KDTree
619
  _SCIPY = True
@@ -640,15 +715,44 @@ def _bridge_wall_endpoints_v2(walls: np.ndarray, cal: WallCalibration,
640
  ii=_ii[ok].astype(np.int64); jj=_jj[ok].astype(np.int64)
641
  if len(ii)==0: return result
642
 
643
- dxij=pts[jj,0]-pts[ii,0]; dyij=pts[jj,1]-pts[ii,1]
644
- dists=np.hypot(dxij,dyij); safe=np.maximum(dists,1e-6)
645
- ux,uy=dxij/safe,dyij/safe
646
- ang=np.degrees(np.arctan2(np.abs(dyij),np.abs(dxij)))
647
- is_H=ang<=angle_tol; is_V=ang>=(90.0-angle_tol)
648
- g1=(dists>=cal.bridge_min_gap)&(dists<=cal.bridge_max_gap); g2=is_H|is_V
649
- g3=((out_dx[ii]*ux+out_dy[ii]*uy)>=FCOS)&((out_dx[jj]*-ux+out_dy[jj]*-uy)>=FCOS)
650
- g4=ep_cc[ii]!=ep_cc[jj]
651
- pre_ok=g1&g2&g3&g4; pre_idx=np.where(pre_ok)[0]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
652
 
653
  N_SAMP=9; clr=np.ones(len(pre_idx),dtype=bool)
654
  for k,pidx in enumerate(pre_idx):
@@ -681,7 +785,7 @@ def _close_door_openings_v2(walls: np.ndarray, cal: WallCalibration) -> np.ndarr
681
  gap=cal.door_gap
682
  def _shape_close(mask, kwh, axis, max_thick):
683
  k=cv2.getStructuringElement(cv2.MORPH_RECT, kwh)
684
- cls=cv2.morphologyEx(mask,cv2.MORPH_CLOSE,k)
685
  new=cv2.bitwise_and(cls,cv2.bitwise_not(mask))
686
  if not np.any(new): return np.zeros_like(mask)
687
  n2,lbl2,st2,_=cv2.connectedComponentsWithStats(new,connectivity=8)
@@ -695,7 +799,6 @@ def _close_door_openings_v2(walls: np.ndarray, cal: WallCalibration) -> np.ndarr
695
 
696
 
697
  def reconstruct_walls(walls: np.ndarray) -> Tuple[np.ndarray, WallCalibration]:
698
- """Full 3-stage wall repair pipeline (5c/5d/5e)."""
699
  cal = calibrate_wall(walls)
700
  walls = _remove_thin_lines_calibrated(walls, cal)
701
  walls = _bridge_wall_endpoints_v2(walls, cal)
@@ -704,7 +807,7 @@ def reconstruct_walls(walls: np.ndarray) -> Tuple[np.ndarray, WallCalibration]:
704
 
705
 
706
  # ════════════════════════════════════════════════════════════════════════════
707
- # REMOVE DANGLING LINES (exact port from GeometryAgent)
708
  # ════════════════════════════════════════════════════════════════════════════
709
 
710
  def remove_dangling_lines(walls: np.ndarray, cal: WallCalibration) -> np.ndarray:
@@ -725,7 +828,7 @@ def remove_dangling_lines(walls: np.ndarray, cal: WallCalibration) -> np.ndarray
725
  bw2=int(stats[cc_id,cv2.CC_STAT_WIDTH]); bh2=int(stats[cc_id,cv2.CC_STAT_HEIGHT])
726
  if max(bw2,bh2) > stroke*40: continue
727
  cm=(cc_map==cc_id).astype(np.uint8)
728
- dc=cv2.dilate(cm,ker)
729
  overlap=cv2.bitwise_and(dc,((walls>0)&(cc_map!=cc_id)).astype(np.uint8))
730
  if np.count_nonzero(overlap)==0: remove[cc_id]=True
731
 
@@ -734,7 +837,7 @@ def remove_dangling_lines(walls: np.ndarray, cal: WallCalibration) -> np.ndarray
734
 
735
 
736
  # ════════════════════════════════════════════════════════════════════════════
737
- # CLOSE LARGE DOOR GAPS (exact port from GeometryAgent 180–320px)
738
  # ════════════════════════════════════════════════════════════════════════════
739
 
740
  def close_large_door_gaps(walls: np.ndarray, cal: WallCalibration) -> np.ndarray:
@@ -768,15 +871,35 @@ def close_large_door_gaps(walls: np.ndarray, cal: WallCalibration) -> np.ndarray
768
  ii=_ii[ok].astype(np.int64); jj=_jj[ok].astype(np.int64)
769
  if len(ii)==0: return result
770
 
771
- dxij=pts[jj,0]-pts[ii,0]; dyij=pts[jj,1]-pts[ii,1]
772
- dists=np.hypot(dxij,dyij); safe=np.maximum(dists,1e-6)
773
- ux,uy=dxij/safe,dyij/safe
774
- ang=np.degrees(np.arctan2(np.abs(dyij),np.abs(dxij)))
775
- is_H=ang<=ANGLE_TOL; is_V=ang>=(90.0-ANGLE_TOL)
776
- g1=(dists>=DOOR_MIN)&(dists<=DOOR_MAX); g2=is_H|is_V
777
- g3=((out_dx[ii]*ux+out_dy[ii]*uy)>=FCOS)&((out_dx[jj]*-ux+out_dy[jj]*-uy)>=FCOS)
778
- g4=ep_cc[ii]!=ep_cc[jj]
779
- pre_ok=g1&g2&g3&g4; pre_idx=np.where(pre_ok)[0]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
780
 
781
  N_SAMP=15; clr=np.ones(len(pre_idx),dtype=bool)
782
  for k,pidx in enumerate(pre_idx):
@@ -808,12 +931,7 @@ def close_large_door_gaps(walls: np.ndarray, cal: WallCalibration) -> np.ndarray
808
  return result
809
 
810
 
811
- def apply_user_lines_to_walls(
812
- walls: np.ndarray,
813
- lines: List[Tuple[int,int,int,int]],
814
- thickness: int,
815
- ) -> np.ndarray:
816
- """Paint user-drawn door-closing lines onto the wall mask."""
817
  result = walls.copy()
818
  for x1, y1, x2, y2 in lines:
819
  cv2.line(result, (x1, y1), (x2, y2), 255, max(thickness, 3))
@@ -821,24 +939,19 @@ def apply_user_lines_to_walls(
821
 
822
 
823
  def segment_rooms_flood(walls: np.ndarray) -> np.ndarray:
824
- """Flood-fill room segmentation. Does NOT modify the input."""
825
  h, w = walls.shape
826
- work = walls.copy() # never mutate the caller's array
827
- # seal border so exterior flood fill can reach everywhere outside walls
828
  work[:5, :] = 255; work[-5:, :] = 255
829
  work[:, :5] = 255; work[:, -5:] = 255
830
-
831
  filled = work.copy()
832
  mask = np.zeros((h+2, w+2), np.uint8)
833
  for sx, sy in [(0,0),(w-1,0),(0,h-1),(w-1,h-1),
834
  (w//2,0),(w//2,h-1),(0,h//2),(w-1,h//2)]:
835
  if filled[sy, sx] == 0:
836
  cv2.floodFill(filled, mask, (sx, sy), 255)
837
-
838
  rooms = cv2.bitwise_not(filled)
839
- # remove wall pixels from room mask (use original walls, not border-sealed)
840
  rooms = cv2.bitwise_and(rooms, cv2.bitwise_not(walls))
841
- rooms = cv2.morphologyEx(rooms, cv2.MORPH_OPEN, np.ones((2,2), np.uint8))
842
  return rooms
843
 
844
 
@@ -856,55 +969,38 @@ def _morphological_skeleton(binary: np.ndarray) -> np.ndarray:
856
  return skel
857
 
858
 
859
- def _find_thick_wall_neg_prompts(
860
- walls_mask: np.ndarray, n: int = SAM_WALL_NEG
861
- ) -> List[Tuple[int,int]]:
862
  h, w = walls_mask.shape
863
  dist = cv2.distanceTransform(walls_mask, cv2.DIST_L2, cv2.DIST_MASK_PRECISE)
864
  try:
865
- skel = cv2.ximgproc.thinning(
866
- walls_mask, thinningType=cv2.ximgproc.THINNING_ZHANGSUEN
867
- )
868
  except AttributeError:
869
  skel = _morphological_skeleton(walls_mask)
870
-
871
  skel_vals = dist[skel > 0]
872
- if len(skel_vals) == 0:
873
- return []
874
  thr = max(float(np.percentile(skel_vals, SAM_WALL_PCT)), WALL_MIN_HALF_PX)
875
  ys, xs = np.where((skel > 0) & (dist >= thr))
876
- if len(ys) == 0:
877
- return []
878
-
879
  grid_cells = max(1, int(np.ceil(np.sqrt(n * 4))))
880
- cell_h = max(1, h // grid_cells)
881
- cell_w = max(1, w // grid_cells)
882
- cell_ids = (ys // cell_h) * grid_cells + (xs // cell_w)
883
- _, first = np.unique(cell_ids, return_index=True)
884
- sel = first[:n]
885
  return [(int(xs[i]), int(ys[i])) for i in sel]
886
 
887
 
888
- def generate_prompts(
889
- walls_mask: np.ndarray, rooms_flood: np.ndarray
890
- ) -> Tuple[np.ndarray, np.ndarray]:
891
  h, w = walls_mask.shape
892
  inv = cv2.bitwise_not(walls_mask)
893
  n, labels, stats, centroids = cv2.connectedComponentsWithStats(inv, connectivity=8)
894
-
895
- # minimum room area = 0.0001 of image β€” much lower than SAM_CLOSET_THR=300 was
896
  min_prompt_area = max(200, int(h * w * 0.0001))
897
-
898
  pts, lbls = [], []
899
  for i in range(1, n):
900
  area = int(stats[i, cv2.CC_STAT_AREA])
901
- if area < min_prompt_area:
902
- continue
903
  bx = int(stats[i, cv2.CC_STAT_LEFT]); by = int(stats[i, cv2.CC_STAT_TOP])
904
  bw = int(stats[i, cv2.CC_STAT_WIDTH]); bh = int(stats[i, cv2.CC_STAT_HEIGHT])
905
- # skip only if it covers virtually the entire image (background region)
906
- if bx <= 2 and by <= 2 and bx+bw >= w-2 and by+bh >= h-2:
907
- continue
908
  cx = int(np.clip(centroids[i][0], 0, w-1))
909
  cy = int(np.clip(centroids[i][1], 0, h-1))
910
  if walls_mask[cy, cx] > 0:
@@ -917,16 +1013,12 @@ def generate_prompts(
917
  if found: break
918
  if not found: continue
919
  pts.append([cx, cy]); lbls.append(1)
920
-
921
  for pt in _find_thick_wall_neg_prompts(walls_mask):
922
  pts.append(list(pt)); lbls.append(0)
923
-
924
- print(f" [prompts] {sum(l==1 for l in lbls)} positive + "
925
- f"{sum(l==0 for l in lbls)} negative prompts")
926
  return np.array(pts, dtype=np.float32), np.array(lbls, dtype=np.int32)
927
 
928
 
929
- def mask_to_rle(mask: np.ndarray) -> Dict:
930
  h, w = mask.shape
931
  flat = mask.flatten(order='F').astype(bool)
932
  counts, run, cur = [], 0, False
@@ -938,21 +1030,23 @@ def mask_to_rle(mask: np.ndarray) -> Dict:
938
  return {"counts": counts, "size": [h, w]}
939
 
940
 
941
- def segment_with_sam(
942
- img_rgb: np.ndarray,
943
- walls: np.ndarray,
944
- sam_ckpt: str,
945
- rooms_flood: Optional[np.ndarray] = None,
946
- ) -> Tuple[np.ndarray, List[Dict]]:
947
  """
948
- Exact port of GeometryAgent.segment_with_sam().
949
- Returns (rooms_mask, sam_room_masks_list).
950
- rooms_mask is a single combined binary mask β€” same as GeometryAgent output.
 
 
 
951
  """
952
  if rooms_flood is None:
953
  rooms_flood = segment_rooms_flood(walls.copy())
954
 
955
- sam_room_masks: List[Dict] = [] # mirrors self._sam_room_masks
956
 
957
  try:
958
  import torch
@@ -963,7 +1057,7 @@ def segment_with_sam(
963
  return rooms_flood, []
964
 
965
  device = "cuda" if torch.cuda.is_available() else "cpu"
966
- print(f" [SAM] Loading vit_h on {device}")
967
  sam = sam_model_registry["vit_h"](checkpoint=sam_ckpt)
968
  sam.to(device); sam.eval()
969
  predictor = SamPredictor(sam)
@@ -974,14 +1068,15 @@ def segment_with_sam(
974
 
975
  all_points, all_labels = generate_prompts(walls, rooms_flood)
976
  if len(all_points) == 0:
977
- print(" [SAM] No prompt centroids β€” using flood-fill")
978
  return rooms_flood, []
979
 
980
  pos_pts = [(p, l) for p, l in zip(all_points, all_labels) if l == 1]
981
  neg_pts = [p for p, l in zip(all_points, all_labels) if l == 0]
982
- print(f" [SAM] {len(pos_pts)} room prompts + {len(neg_pts)} wall-negative prompts")
983
 
984
- predictor.set_image(img_rgb)
 
 
985
 
986
  h, w = walls.shape
987
  sam_mask = np.zeros((h, w), dtype=np.uint8)
@@ -991,85 +1086,77 @@ def segment_with_sam(
991
  neg_lbls = np.zeros(len(neg_pts), dtype=np.int32) if neg_pts else None
992
  denoise_k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
993
 
994
- for (px, py), lbl in pos_pts:
995
- px, py = int(px), int(py)
996
- if neg_coords is not None:
997
- pt_c = np.vstack([[[px, py]], neg_coords])
998
- pt_l = np.concatenate([[lbl], neg_lbls])
999
- else:
1000
- pt_c = np.array([[px, py]], dtype=np.float32)
1001
- pt_l = np.array([lbl], dtype=np.int32)
1002
-
1003
- try:
1004
- masks, scores, _ = predictor.predict(
1005
- point_coords=pt_c, point_labels=pt_l, multimask_output=True
1006
- )
1007
- except Exception as e:
1008
- print(f" [SAM] predict failed ({e})")
1009
- continue
1010
-
1011
- best_idx = int(np.argmax(scores))
1012
- best_score = float(scores[best_idx])
1013
- if best_score < SAM_MIN_SCORE:
1014
- continue
1015
-
1016
- best_mask = (masks[best_idx] > 0).astype(np.uint8) * 255
1017
- # AND with flood-fill constraint β€” exact same as GeometryAgent
1018
- best_mask = cv2.bitwise_and(best_mask, rooms_flood)
1019
- best_mask = cv2.morphologyEx(best_mask, cv2.MORPH_OPEN, denoise_k, iterations=1)
1020
-
1021
- if not np.any(best_mask):
1022
- continue
1023
-
1024
- sam_room_masks.append({
1025
- "mask" : best_mask.copy(),
1026
- "score" : best_score,
1027
- "prompt": (px, py),
1028
- })
1029
- sam_mask = cv2.bitwise_or(sam_mask, best_mask)
1030
- accepted += 1
1031
 
1032
- print(f" [SAM] Accepted {accepted}/{len(pos_pts)} masks")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1033
 
1034
  if accepted == 0:
1035
- print(" [SAM] No masks accepted β€” using flood-fill")
1036
  return rooms_flood, []
1037
 
1038
  return sam_mask, sam_room_masks
1039
 
1040
 
1041
- def _flood_fill_fallback(rooms_flood: np.ndarray) -> Tuple[np.ndarray, List]:
1042
- """Mirrors GeometryAgent flood-fill path β€” returns (rooms_mask, [])."""
1043
- return rooms_flood, []
1044
-
1045
-
1046
- # ════════════════════════════════════════════════════════════════════════════
1047
- # FILTER ROOM REGIONS β€” exact port of GeometryAgent.filter_room_regions()
1048
- # ════════════════════════════════════════════════════════════════════════════
1049
-
1050
- def filter_room_regions(
1051
- rooms_mask: np.ndarray, img_shape: Tuple
1052
- ) -> Tuple[np.ndarray, List]:
1053
- """
1054
- Exact port of GeometryAgent.filter_room_regions().
1055
- Finds contours on the COMBINED rooms_mask, filters them.
1056
- Returns (valid_mask, valid_rooms) where valid_rooms is a list of contours.
1057
- """
1058
  h, w = img_shape[:2]
1059
  img_area = float(h * w)
1060
-
1061
  min_area = img_area * MIN_ROOM_AREA_FRAC
1062
  max_area = img_area * MAX_ROOM_AREA_FRAC
1063
  min_dim = w * MIN_ROOM_DIM_FRAC
1064
  margin = max(5.0, w * BORDER_MARGIN_FRAC)
1065
 
1066
- print(f" [filter] min_area={min_area:.0f} max_area={max_area:.0f} "
1067
- f"min_dim={min_dim:.0f} margin={margin:.0f}")
1068
-
1069
  contours, _ = cv2.findContours(rooms_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
1070
- if not contours:
1071
- print(" [filter] no contours found in rooms_mask")
1072
- return np.zeros_like(rooms_mask), []
1073
 
1074
  bboxes = np.array([cv2.boundingRect(c) for c in contours], dtype=np.float32)
1075
  areas = np.array([cv2.contourArea(c) for c in contours], dtype=np.float32)
@@ -1082,7 +1169,6 @@ def filter_room_regions(
1082
  aspect = np.maximum(bw_arr, bh_arr) / (np.minimum(bw_arr, bh_arr) + 1e-6)
1083
  aspect_ok = aspect <= MAX_ASPECT_RATIO
1084
  extent_ok = (areas / (bw_arr * bh_arr + 1e-6)) >= MIN_EXTENT
1085
-
1086
  cheap_pass = np.where(area_ok & border_ok & dim_ok & aspect_ok & extent_ok)[0]
1087
 
1088
  valid_mask = np.zeros_like(rooms_mask)
@@ -1095,139 +1181,90 @@ def filter_room_regions(
1095
  cv2.drawContours(valid_mask, [cnt], -1, 255, -1)
1096
  valid_rooms.append(cnt)
1097
 
1098
- print(f" [filter] {len(valid_rooms)} valid rooms from {len(contours)} contours "
1099
- f"(area_ok={int(area_ok.sum())} border_ok={int((area_ok&border_ok).sum())} "
1100
- f"dim_ok={int((area_ok&border_ok&dim_ok).sum())})")
1101
  return valid_mask, valid_rooms
1102
 
1103
 
1104
- def pixel_area_to_m2(area_px: float) -> float:
1105
  return area_px * (2.54 / DPI) ** 2 * (SCALE_FACTOR ** 2) / 10000
1106
 
1107
 
1108
- # ════════════════════════════════════════════════════════════════════════════
1109
- # SAM MASK β†’ CONTOUR β€” exact port of GeometryAgent._match_sam_mask_to_contour
1110
- # ════════════════════════════════════════════════════════════════════════════
1111
-
1112
- def _mask_to_contour_flat(mask: np.ndarray) -> List[float]:
1113
- """Exact port of GeometryAgent._mask_to_contour_flat()."""
1114
  contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
1115
- if not contours:
1116
- return []
1117
  largest = max(contours, key=cv2.contourArea)
1118
  pts = largest[:, 0, :].tolist()
1119
  return [v for pt in pts for v in pt]
1120
 
1121
 
1122
- def _match_sam_mask_to_contour(
1123
- contour: np.ndarray,
1124
- sam_room_masks: List[Dict],
1125
- ) -> Tuple[Dict, List[float], float]:
1126
- """
1127
- Exact port of GeometryAgent._match_sam_mask_to_contour().
1128
- Returns (rle_dict, sam_contour_flat, score).
1129
- """
1130
  if not sam_room_masks:
1131
  return _contour_to_rle_and_flat(contour)
1132
-
1133
  sam_h, sam_w = sam_room_masks[0]["mask"].shape
1134
  contour_mask = np.zeros((sam_h, sam_w), dtype=np.uint8)
1135
  cv2.drawContours(contour_mask, [contour], -1, 255, thickness=-1)
1136
-
1137
- best_iou = 0.0
1138
- best_entry = None
1139
-
1140
  for entry in sam_room_masks:
1141
  m = entry["mask"]
1142
- if m.shape != contour_mask.shape:
1143
- continue
1144
  inter = np.count_nonzero(cv2.bitwise_and(m, contour_mask))
1145
- if inter == 0:
1146
- continue
1147
  union = np.count_nonzero(cv2.bitwise_or(m, contour_mask))
1148
  iou = inter / (union + 1e-6)
1149
- if iou > best_iou:
1150
- best_iou = iou
1151
- best_entry = entry
1152
-
1153
  if best_entry is None or best_iou < 0.05:
1154
  return _contour_to_rle_and_flat(contour)
1155
-
1156
  sam_contour_flat = _mask_to_contour_flat(best_entry["mask"])
1157
  if not sam_contour_flat:
1158
  raw_pts = contour[:, 0, :].tolist()
1159
  sam_contour_flat = [v for pt in raw_pts for v in pt]
1160
-
1161
  return mask_to_rle(best_entry["mask"]), sam_contour_flat, best_entry["score"]
1162
 
1163
 
1164
- def _contour_to_rle_and_flat(contour: np.ndarray) -> Tuple[Dict, List[float], float]:
1165
- """Fallback when no SAM mask matches β€” exact port of GeometryAgent._contour_to_rle_and_flat()."""
1166
  x, y, rw, rh = cv2.boundingRect(contour)
1167
- canvas_h = rh + y + 20
1168
- canvas_w = rw + x + 20
1169
- canvas = np.zeros((canvas_h, canvas_w), dtype=np.uint8)
1170
  cv2.drawContours(canvas, [contour], -1, 255, thickness=-1)
1171
  raw_pts = contour[:, 0, :].tolist()
1172
  flat_pts = [v for pt in raw_pts for v in pt]
1173
  return mask_to_rle(canvas), flat_pts, 1.0
1174
 
1175
 
1176
- # ════════════════════════════════════════════════════════════════════════════
1177
- # MEASURE AND LABEL ROOMS β€” exact port of GeometryAgent.measure_and_label_rooms
1178
- # ════════════════════════════════════════════════════════════════════════════
1179
-
1180
- def measure_and_label_rooms(
1181
- img: np.ndarray,
1182
- valid_rooms: List,
1183
- sam_room_masks: List[Dict],
1184
- ) -> List[Dict]:
1185
- """Exact port of GeometryAgent.measure_and_label_rooms()."""
1186
  room_data = []
1187
-
1188
  for idx, contour in enumerate(valid_rooms, 1):
1189
  x, y, rw, rh = cv2.boundingRect(contour)
1190
-
1191
- # OCR β€” try to find a room label
1192
  label = run_ocr_on_room(img, contour)
1193
  if not label or not validate_label(label):
1194
- # GeometryAgent skips rooms with no valid label
1195
- # but here we keep them with a fallback so users can see them
1196
  label = f"ROOM {idx}"
1197
-
1198
  area_px = cv2.contourArea(contour)
1199
  M = cv2.moments(contour)
1200
  cx = int(M["m10"] / M["m00"]) if M["m00"] else x + rw // 2
1201
  cy = int(M["m01"] / M["m00"]) if M["m00"] else y + rh // 2
1202
-
1203
  _, raw_seg_flat, sam_score = _match_sam_mask_to_contour(contour, sam_room_masks)
1204
-
1205
  room_data.append({
1206
- "id" : len(room_data) + 1,
1207
- "label" : label,
1208
- "contour" : contour, # kept for annotation
1209
- "segmentation" : [raw_seg_flat],
1210
- "raw_segmentation": [raw_seg_flat],
1211
- "sam_score" : round(sam_score, 4),
1212
- "score" : round(sam_score, 4),
1213
- "area" : area_px,
1214
- "area_px" : area_px,
1215
- "area_m2" : round(pixel_area_to_m2(area_px), 2),
1216
- "bbox" : [x, y, rw, rh],
1217
- "centroid" : [cx, cy],
1218
- "confidence": 0.95,
1219
- "isAi" : True,
1220
  })
1221
-
1222
- print(f" [label] {len(room_data)} rooms labeled")
1223
  return room_data
1224
 
1225
 
 
 
 
 
 
1226
  def run_ocr_on_room(img_bgr: np.ndarray, contour: np.ndarray) -> Optional[str]:
1227
  try:
1228
  import easyocr
1229
  if not hasattr(run_ocr_on_room, "_reader"):
1230
- run_ocr_on_room._reader = easyocr.Reader(["en"], gpu=False)
 
 
1231
  reader = run_ocr_on_room._reader
1232
  except ImportError:
1233
  return None
@@ -1236,15 +1273,15 @@ def run_ocr_on_room(img_bgr: np.ndarray, contour: np.ndarray) -> Optional[str]:
1236
  pad = 20
1237
  roi = img_bgr[max(0,y-pad):min(img_bgr.shape[0],y+rh+pad),
1238
  max(0,x-pad):min(img_bgr.shape[1],x+rw+pad)]
1239
- if roi.size == 0:
1240
- return None
1241
 
1242
  gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
1243
  clahe = cv2.createCLAHE(2.0, (8,8))
1244
  proc = clahe.apply(gray)
1245
- _, bin_img = cv2.threshold(proc, 0, 255, cv2.THRESH_BINARY+cv2.THRESH_OTSU)
1246
- rgb = cv2.cvtColor(cv2.medianBlur(bin_img, 3), cv2.COLOR_GRAY2RGB)
1247
-
 
1248
  try:
1249
  results = reader.readtext(rgb, detail=1, paragraph=False)
1250
  cands = [
@@ -1257,7 +1294,7 @@ def run_ocr_on_room(img_bgr: np.ndarray, contour: np.ndarray) -> Optional[str]:
1257
  return None
1258
 
1259
 
1260
- def validate_label(label: str) -> bool:
1261
  if not label: return False
1262
  label = label.strip()
1263
  if not label[0].isalpha(): return False
@@ -1265,414 +1302,232 @@ def validate_label(label: str) -> bool:
1265
  return lc == 1 or lc >= 3
1266
 
1267
 
1268
- def build_annotated_image(
1269
- img_bgr: np.ndarray,
1270
- rooms: List[Dict],
1271
- selected_ids: Optional[List[int]] = None,
1272
- ) -> np.ndarray:
1273
- vis = img_bgr.copy()
1274
- overlay = vis.copy()
1275
-
1276
  for i, room in enumerate(rooms):
1277
  color = ROOM_COLORS[i % len(ROOM_COLORS)]
1278
  bgr = (color[2], color[1], color[0])
1279
  cnt = room.get("contour")
1280
  if cnt is None: continue
1281
-
1282
  cv2.drawContours(overlay, [cnt], -1, bgr, -1)
1283
- alpha = 0.35
1284
- vis = cv2.addWeighted(overlay, alpha, vis, 1-alpha, 0)
1285
  overlay = vis.copy()
1286
-
1287
  is_sel = selected_ids and room["id"] in selected_ids
1288
- border_t = 4 if is_sel else 2
1289
- border_c = (0, 255, 255) if is_sel else bgr
1290
- cv2.drawContours(vis, [cnt], -1, border_c, border_t)
1291
-
1292
  M = cv2.moments(cnt)
1293
  cx = int(M["m10"]/M["m00"]) if M["m00"] else 0
1294
  cy = int(M["m01"]/M["m00"]) if M["m00"] else 0
1295
-
1296
- label = room.get("label", f"Room {room['id']}")
1297
- area = room.get("area_m2", 0.0)
1298
- text1 = label
1299
- text2 = f"{area:.1f} mΒ²"
1300
-
1301
- fs = 0.55
1302
- th = 1
1303
- (tw1, th1), _ = cv2.getTextSize(text1, cv2.FONT_HERSHEY_SIMPLEX, fs, th)
1304
- (tw2, th2), _ = cv2.getTextSize(text2, cv2.FONT_HERSHEY_SIMPLEX, fs-0.1, th)
1305
-
1306
- bx = cx - max(tw1, tw2)//2 - 4
1307
- by = cy - th1 - th2 - 12
1308
- bw2 = max(tw1, tw2) + 8
1309
- bh2 = th1 + th2 + 16
1310
-
1311
- sub = vis[max(0,by):max(0,by)+bh2, max(0,bx):max(0,bx)+bw2]
1312
  if sub.size > 0:
1313
- white = np.ones_like(sub) * 255
1314
- vis[max(0,by):max(0,by)+bh2, max(0,bx):max(0,bx)+bw2] = \
1315
- cv2.addWeighted(sub, 0.3, white, 0.7, 0)
1316
-
1317
- cv2.putText(vis, text1,
1318
- (cx - tw1//2, cy - th2 - 6),
1319
  cv2.FONT_HERSHEY_SIMPLEX, fs, (20,20,20), th+1, cv2.LINE_AA)
1320
- cv2.putText(vis, text2,
1321
- (cx - tw2//2, cy + th2 + 2),
1322
  cv2.FONT_HERSHEY_SIMPLEX, fs-0.1, (20,20,20), th, cv2.LINE_AA)
1323
-
1324
  return vis
1325
 
1326
 
1327
- def export_to_excel(rooms: List[Dict]) -> str:
1328
- wb = openpyxl.Workbook()
1329
- ws = wb.active
1330
- ws.title = "Room Analysis"
1331
-
1332
- headers = ["ID", "Label", "Area (px)", "Area (mΒ²)", "Centroid X", "Centroid Y",
1333
- "Bbox X", "Bbox Y", "Bbox W", "Bbox H", "SAM Score", "Confidence"]
1334
- header_fill = PatternFill("solid", fgColor="1F4E79")
1335
- header_font = Font(bold=True, color="FFFFFF", size=11)
1336
-
1337
- for col, h in enumerate(headers, 1):
1338
- cell = ws.cell(row=1, column=col, value=h)
1339
- cell.fill = header_fill
1340
- cell.font = header_font
1341
- cell.alignment = Alignment(horizontal="center")
1342
-
1343
- alt_fill = PatternFill("solid", fgColor="D6E4F0")
1344
- for row_n, room in enumerate(rooms, 2):
1345
  cnt = room.get("contour")
1346
  M = cv2.moments(cnt) if cnt is not None else {}
1347
  cx = int(M["m10"]/M["m00"]) if M.get("m00") else 0
1348
  cy = int(M["m01"]/M["m00"]) if M.get("m00") else 0
1349
  bbox = cv2.boundingRect(cnt) if cnt is not None else (0,0,0,0)
1350
-
1351
- row_data = [
1352
- room.get("id"), room.get("label","?"),
1353
- round(room.get("area_px",0),1),
1354
- round(room.get("area_m2",0.0),2),
1355
- cx, cy,
1356
- bbox[0], bbox[1], bbox[2], bbox[3],
1357
- round(room.get("score",1.0),4),
1358
- round(room.get("confidence",0.95),2),
1359
- ]
1360
- fill = alt_fill if row_n % 2 == 0 else None
1361
- for col, val in enumerate(row_data, 1):
1362
- cell = ws.cell(row=row_n, column=col, value=val)
1363
- cell.alignment = Alignment(horizontal="center")
1364
- if fill: cell.fill = fill
1365
-
1366
  for col in ws.columns:
1367
- max_len = max(len(str(c.value or "")) for c in col) + 4
1368
- ws.column_dimensions[col[0].column_letter].width = min(max_len, 25)
1369
-
1370
  out = Path(tempfile.gettempdir()) / f"floorplan_rooms_{int(time.time())}.xlsx"
1371
- wb.save(str(out))
1372
- return str(out)
1373
 
1374
 
1375
  # ════════════════════════════════════════════════════════════════════════════
1376
- # STATE (Gradio state object, passed between callbacks)
1377
  # ════════════════════════════════════════════════════════════════════════════
1378
 
1379
- def init_state() -> Dict:
1380
- return {
1381
- "img_orig": None, # BGR
1382
- "img_cropped": None,
1383
- "img_clean": None,
1384
- "walls": None,
1385
- "walls_base": None, # walls after full pipeline, before user lines
1386
- "wall_cal": None, # WallCalibration
1387
- "user_lines": [], # [(x1,y1,x2,y2), …]
1388
- "draw_start": None, # pending line start pixel
1389
- "walls_thickness": 8,
1390
- "rooms": [], # list of room dicts
1391
- "selected_ids": [],
1392
- "annotated": None, # BGR annotated image
1393
- "status": "Idle",
1394
- }
1395
 
1396
 
1397
  # ════════════════════════════════════════════════════════════════════════════
1398
- # GRADIO CALLBACKS
1399
  # ════════════════════════════════════════════════════════════════════════════
1400
 
1401
  def cb_load_image(upload, state):
1402
  if upload is None:
1403
  return None, state, "Upload a floor-plan image to begin."
1404
- # Gradio 6: UploadButton returns a NamedString (file path) or a dict
1405
  try:
1406
- if hasattr(upload, "name"):
1407
- file_path = upload.name # NamedString / tempfile path
1408
- elif isinstance(upload, dict) and "name" in upload:
1409
- file_path = upload["name"]
1410
- elif isinstance(upload, str):
1411
- file_path = upload
1412
  else:
1413
- # bytes fallback
1414
- img_bgr = cv2.imdecode(
1415
- np.frombuffer(bytes(upload), dtype=np.uint8), cv2.IMREAD_COLOR
1416
- )
1417
- file_path = None
1418
-
1419
- if file_path is not None:
1420
- img_bgr = cv2.imread(file_path)
1421
  except Exception as e:
1422
  return None, state, f"❌ Error reading upload: {e}"
1423
-
1424
- if img_bgr is None:
1425
- return None, state, "❌ Could not decode image."
1426
- state = init_state()
1427
- state["img_orig"] = img_bgr
1428
- state["status"] = "Image loaded."
1429
- preview = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
1430
- return preview, state, f"βœ… Loaded {img_bgr.shape[1]}Γ—{img_bgr.shape[0]} px"
1431
 
1432
 
1433
  def cb_preprocess(state):
1434
- img = state.get("img_orig")
1435
- if img is None:
1436
- return None, None, state, "Load an image first."
1437
-
1438
- # ── Step 1: crop title block ──────────────────────────────────────────
1439
- cropped = remove_title_block(img)
1440
-
1441
- # ── Step 2: remove CAD colours ────────────────────────────────────────
1442
  img_clean = remove_colors(cropped)
1443
-
1444
- # ── Step 3: close door arcs (before wall extraction) ─────────────────
1445
  img_clean = detect_and_close_door_arcs(img_clean)
1446
-
1447
- # ── Step 4: brightness-aware image stats ─────────────────────────────
1448
  img_stats = analyze_image_characteristics(cropped)
1449
-
1450
- # ── Step 5: extract walls adaptive (brightness-aware + double-line filter) ──
1451
  walls, thick = extract_walls_adaptive(img_clean, img_stats)
1452
-
1453
- # ── Step 5b: remove fixture symbols (toilets / stalls) ───────────────
1454
  walls = remove_fixture_symbols(walls)
1455
-
1456
- # ── Step 5c/5d/5e: calibrated 3-stage wall reconstruction ────────────
1457
  walls, cal = reconstruct_walls(walls)
1458
-
1459
- # ── Step 5f: remove dangling unconnected stubs ────────────────────────
1460
  walls = remove_dangling_lines(walls, cal)
1461
-
1462
- # ── Step 5g: close large door gaps (180–320 px) ──────────────────────
1463
  walls = close_large_door_gaps(walls, cal)
1464
-
1465
- state["img_cropped"] = cropped
1466
- state["img_clean"] = img_clean
1467
- state["walls"] = walls.copy()
1468
- state["walls_base"] = walls.copy() # kept for undo recompute
1469
- state["walls_thickness"] = thick
1470
- state["wall_cal"] = cal
1471
-
1472
- walls_rgb = cv2.cvtColor(walls, cv2.COLOR_GRAY2RGB)
1473
- clean_rgb = cv2.cvtColor(img_clean, cv2.COLOR_BGR2RGB)
1474
- msg = (f"βœ… Full pipeline done | strokeβ‰ˆ{cal.stroke_width}px "
1475
- f"bodyβ‰ˆ{thick}px bridge_gap=[{cal.bridge_min_gap},{cal.bridge_max_gap}]px "
1476
- f"door_gap={cal.door_gap}px")
1477
  return clean_rgb, walls_rgb, state, msg
1478
 
1479
 
1480
  def cb_add_door_line(evt: gr.SelectData, state):
1481
- """
1482
- Two-click line drawing on the wall image.
1483
- First click β†’ sets start, second click β†’ draws line and resets.
1484
- """
1485
- walls = state.get("walls")
1486
- if walls is None:
1487
- return None, state, "Run preprocessing first."
1488
-
1489
- x, y = int(evt.index[0]), int(evt.index[1])
1490
-
1491
  if state["draw_start"] is None:
1492
- state["draw_start"] = (x, y)
1493
- msg = f"πŸ–Š Start point set ({x},{y}). Click end point."
1494
  else:
1495
- x1, y1 = state["draw_start"]
1496
- state["user_lines"].append((x1, y1, x, y))
1497
- state["draw_start"] = None
1498
-
1499
- # apply all lines to walls
1500
- walls_upd = apply_user_lines_to_walls(
1501
- state["walls"], state["user_lines"], state["walls_thickness"]
1502
- )
1503
- state["walls"] = walls_upd
1504
-
1505
- vis = cv2.cvtColor(walls_upd, cv2.COLOR_GRAY2RGB)
1506
- for lx1, ly1, lx2, ly2 in state["user_lines"]:
1507
- cv2.line(vis, (lx1,ly1), (lx2,ly2), (255,80,80), 3)
1508
- return vis, state, f"βœ… Door line drawn ({x1},{y1})β†’({x},{y}) Total: {len(state['user_lines'])}"
1509
-
1510
- vis = cv2.cvtColor(walls, cv2.COLOR_GRAY2RGB)
1511
- for lx1, ly1, lx2, ly2 in state["user_lines"]:
1512
- cv2.line(vis, (lx1,ly1), (lx2,ly2), (255,80,80), 3)
1513
- if state["draw_start"]:
1514
- cv2.circle(vis, state["draw_start"], 6, (0,200,255), -1)
1515
- return vis, state, msg
1516
 
1517
 
1518
  def cb_undo_door_line(state):
1519
- if not state["user_lines"]:
1520
- return None, state, "No lines to undo."
1521
- state["user_lines"].pop()
1522
- state["draw_start"] = None
1523
-
1524
- walls_base = state.get("walls_base")
1525
- if walls_base is None:
1526
- return None, state, "Re-run preprocessing."
1527
-
1528
- thick = state.get("walls_thickness", 8)
1529
- walls_upd = apply_user_lines_to_walls(walls_base, state["user_lines"], thick)
1530
- state["walls"] = walls_upd
1531
-
1532
- vis = cv2.cvtColor(walls_upd, cv2.COLOR_GRAY2RGB)
1533
- for lx1, ly1, lx2, ly2 in state["user_lines"]:
1534
- cv2.line(vis, (lx1,ly1), (lx2,ly2), (255,80,80), 3)
1535
- return vis, state, f"↩ Last line removed. Remaining: {len(state['user_lines'])}"
1536
 
1537
 
1538
  def cb_run_sam(state):
1539
- walls = state.get("walls")
1540
- img = state.get("img_cropped")
1541
- img_clean = state.get("img_clean")
1542
- if walls is None or img is None:
1543
- return None, None, state, "Run preprocessing first."
1544
-
1545
- print(f"[SAM] walls={walls.shape} wall_px={np.count_nonzero(walls)}")
1546
-
1547
- img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
1548
- ckpt = download_sam_if_needed()
1549
-
1550
- # ── Segment (SAM or flood-fill fallback) β€” mirrors GeometryAgent.run() ──
1551
- sam_enabled = ckpt is not None and Path(ckpt).exists()
1552
-
1553
  if sam_enabled:
1554
- rooms_mask, sam_room_masks = segment_with_sam(img_rgb, walls.copy(), ckpt)
1555
  else:
1556
- print(" [SAM] SAM disabled β€” using flood-fill")
1557
- rooms_mask = segment_rooms_flood(walls.copy())
1558
- sam_room_masks = []
1559
-
1560
- # store sam_room_masks for _match_sam_mask_to_contour
1561
- state["_sam_room_masks"] = sam_room_masks
1562
-
1563
- flood_px = int(np.count_nonzero(rooms_mask))
1564
- print(f"[SAM] rooms_mask px={flood_px}")
1565
- if flood_px == 0:
1566
- return None, None, state, "⚠ rooms_mask is empty β€” walls may be blocking everything."
1567
-
1568
- # ── filter_room_regions β€” exact GeometryAgent method ────────────────────
1569
- valid_mask, valid_rooms = filter_room_regions(rooms_mask, img.shape)
1570
- if not valid_rooms:
1571
- return None, None, state, "⚠ No valid room regions detected after filtering."
1572
-
1573
- # ── measure_and_label_rooms ──────────────────────────────────────────────
1574
- src = img_clean if img_clean is not None else img
1575
- rooms = measure_and_label_rooms(src, valid_rooms, sam_room_masks)
1576
- if not rooms:
1577
- return None, None, state, "⚠ No rooms passed labeling / OCR."
1578
-
1579
- state["rooms"] = rooms
1580
- state["selected_ids"] = []
1581
-
1582
- annotated = build_annotated_image(img, rooms)
1583
- state["annotated"] = annotated
1584
-
1585
- table = [[r["id"], r["label"], f"{r['area_m2']} mΒ²", f"{r['score']:.2f}"]
1586
- for r in rooms]
1587
- ann_rgb = cv2.cvtColor(annotated, cv2.COLOR_BGR2RGB)
1588
- return ann_rgb, table, state, f"βœ… {len(rooms)} rooms detected."
1589
 
1590
 
1591
  def cb_click_room(evt: gr.SelectData, state):
1592
- annotated = state.get("annotated")
1593
- rooms = state.get("rooms", [])
1594
- img = state.get("img_cropped")
1595
- if annotated is None or not rooms:
1596
- return None, state, "Run SAM first."
1597
-
1598
- x, y = int(evt.index[0]), int(evt.index[1])
1599
- clicked_id = None
1600
  for room in rooms:
1601
- cnt = room.get("contour")
1602
  if cnt is None: continue
1603
- if cv2.pointPolygonTest(cnt, (float(x), float(y)), False) >= 0:
1604
- clicked_id = room["id"]
1605
- break
1606
-
1607
  if clicked_id is None:
1608
- state["selected_ids"] = []
1609
- msg = "Clicked outside all rooms β€” selection cleared."
1610
  else:
1611
- sel = state["selected_ids"]
1612
- if clicked_id in sel:
1613
- sel.remove(clicked_id)
1614
- msg = f"Room {clicked_id} deselected."
1615
- else:
1616
- sel.append(clicked_id)
1617
- msg = f"Room {clicked_id} selected."
1618
- state["selected_ids"] = sel
1619
-
1620
- new_ann = build_annotated_image(img, rooms, state["selected_ids"])
1621
- state["annotated"] = new_ann
1622
- return cv2.cvtColor(new_ann, cv2.COLOR_BGR2RGB), state, msg
1623
 
1624
 
1625
  def cb_remove_selected(state):
1626
- sel = state.get("selected_ids", [])
1627
- rooms = state.get("rooms", [])
1628
- img = state.get("img_cropped")
1629
- if not sel:
1630
- return None, None, state, "No rooms selected."
1631
-
1632
- removed = [r["label"] for r in rooms if r["id"] in sel]
1633
- rooms = [r for r in rooms if r["id"] not in sel]
1634
- for i, r in enumerate(rooms, 1):
1635
- r["id"] = i
1636
- state["rooms"] = rooms
1637
- state["selected_ids"] = []
1638
-
1639
- ann = build_annotated_image(img, rooms)
1640
- state["annotated"] = ann
1641
-
1642
- table = [[r["id"], r["label"], f"{r['area_m2']} mΒ²", f"{r['score']:.2f}"]
1643
- for r in rooms]
1644
- return cv2.cvtColor(ann, cv2.COLOR_BGR2RGB), table, state, \
1645
- f"πŸ—‘ Removed: {', '.join(removed)}"
1646
 
1647
 
1648
  def cb_rename_selected(new_label, state):
1649
- sel = state.get("selected_ids", [])
1650
- rooms = state.get("rooms", [])
1651
- img = state.get("img_cropped")
1652
- if not sel:
1653
- return None, None, state, "Select a room first."
1654
- if not new_label.strip():
1655
- return None, None, state, "Enter a non-empty label."
1656
-
1657
  for r in rooms:
1658
- if r["id"] in sel:
1659
- r["label"] = new_label.strip().upper()
1660
- state["rooms"] = rooms
1661
-
1662
- ann = build_annotated_image(img, rooms, sel)
1663
- state["annotated"] = ann
1664
- table = [[r["id"], r["label"], f"{r['area_m2']} mΒ²", f"{r['score']:.2f}"]
1665
- for r in rooms]
1666
- return cv2.cvtColor(ann, cv2.COLOR_BGR2RGB), table, state, \
1667
- f"✏ Renamed to '{new_label.strip().upper()}'"
1668
 
1669
 
1670
  def cb_export_excel(state):
1671
- rooms = state.get("rooms", [])
1672
- if not rooms:
1673
- return None, "No rooms to export."
1674
- path = export_to_excel(rooms)
1675
- return path, f"βœ… Exported {len(rooms)} rooms β†’ {Path(path).name}"
1676
 
1677
 
1678
  # ════════════════════════════════════════════════════════════════════════════
@@ -1680,156 +1535,75 @@ def cb_export_excel(state):
1680
  # ════════════════════════════════════════════════════════════════════════════
1681
 
1682
  CSS = """
1683
- #title { text-align: center; font-size: 1.8em; font-weight: 700; color: #1F4E79; }
1684
- #subtitle { text-align: center; color: #555; margin-top: -8px; margin-bottom: 16px; }
1685
- .step-card { border-left: 4px solid #1F4E79 !important; padding-left: 10px !important; }
1686
  """
1687
 
1688
-
1689
  def _walls_to_rgb(s):
1690
- """Helper used in .then() β€” must be a named function for Gradio 6."""
1691
- w = s.get("walls")
1692
- if w is None:
1693
- return None
1694
- return cv2.cvtColor(w, cv2.COLOR_GRAY2RGB)
1695
 
1696
 
1697
- with gr.Blocks(title="FloorPlan Analyser") as app:
1698
- state = gr.State(init_state())
1699
-
1700
- gr.Markdown("# 🏒 Floor Plan Room Analyser", elem_id="title")
1701
  gr.Markdown(
1702
- "Upload a floor-plan β†’ auto-extract walls β†’ close doors β†’ SAM segmentation β†’ OCR labels β†’ export Excel",
 
 
 
1703
  elem_id="subtitle",
1704
  )
 
1705
 
1706
- status_box = gr.Textbox(
1707
- label="Status",
1708
- interactive=False,
1709
- value="Idle β€” upload a floor plan to begin.",
1710
- )
1711
-
1712
- # ── Row 1: Upload + Preprocessing ───────────────────────────────────────
1713
  with gr.Row():
1714
- with gr.Column(scale=1, elem_classes="step-card"):
1715
  gr.Markdown("### 1️⃣ Upload Floor Plan")
1716
- upload_btn = gr.UploadButton("πŸ“‚ Upload Image", file_types=["image"], size="sm")
1717
- raw_preview = gr.Image(label="Loaded Image", height=320)
1718
-
1719
- with gr.Column(scale=1, elem_classes="step-card"):
1720
- gr.Markdown("### 2️⃣ Pre-process (Crop β†’ De-color β†’ Walls)")
1721
- preprocess_btn = gr.Button("βš™ Run Preprocessing", variant="primary")
1722
  with gr.Tabs():
1723
- with gr.Tab("Clean Image"):
1724
- clean_img = gr.Image(label="After color removal", height=300)
1725
- with gr.Tab("Walls"):
1726
- walls_img = gr.Image(label="Extracted walls", height=300)
1727
 
1728
- # ── Row 2: Door Line Drawing ─────────────────────────────────────────────
1729
  with gr.Row():
1730
  with gr.Column(elem_classes="step-card"):
1731
- gr.Markdown("### 3️⃣ Draw Door-Closing Lines *(click start β†’ click end)*")
1732
- gr.Markdown(
1733
- "Click the wall image below: **first click** = line start, "
1734
- "**second click** = line end. Lines are burned into the wall mask "
1735
- "before SAM runs to prevent room leakage through open doors."
1736
- )
1737
- undo_line_btn = gr.Button("↩ Undo Last Line", size="sm")
1738
- wall_draw_img = gr.Image(
1739
- label="Wall mask β€” click to draw door-closing lines",
1740
- height=380,
1741
- interactive=False,
1742
- )
1743
-
1744
- # ── Row 3: SAM + Annotation ──────────────────────────────────────────────
1745
  with gr.Row():
1746
- with gr.Column(scale=2, elem_classes="step-card"):
1747
  gr.Markdown("### 4️⃣ SAM Segmentation + OCR")
1748
- sam_btn = gr.Button("πŸ€– Run SAM + OCR", variant="primary")
1749
- ann_img = gr.Image(
1750
- label="Annotated rooms β€” click to select / deselect",
1751
- height=480,
1752
- interactive=False,
1753
- )
1754
-
1755
- with gr.Column(scale=1, elem_classes="step-card"):
1756
  gr.Markdown("### 5️⃣ Room Table & Actions")
1757
- room_table = gr.Dataframe(
1758
- headers=["ID", "Label", "Area", "SAM Score"],
1759
- datatype=["number", "str", "str", "str"],
1760
- interactive=False,
1761
- label="Detected Rooms",
1762
- )
1763
  with gr.Group():
1764
- gr.Markdown("**Edit selected room(s)**")
1765
- rename_txt = gr.Textbox(placeholder="New label…", label="Rename Label")
1766
  with gr.Row():
1767
- rename_btn = gr.Button("✏ Rename", size="sm")
1768
- remove_btn = gr.Button("πŸ—‘ Remove Selected", size="sm", variant="stop")
1769
-
1770
  gr.Markdown("---")
1771
- export_btn = gr.Button("πŸ“Š Export to Excel", variant="secondary")
1772
- excel_file = gr.File(label="Download Excel", visible=True)
1773
-
1774
- # ── Event Wiring ─────────────────────────────────────────────────────────
1775
-
1776
- upload_btn.upload(
1777
- cb_load_image,
1778
- inputs=[upload_btn, state],
1779
- outputs=[raw_preview, state, status_box],
1780
- )
1781
-
1782
- preprocess_btn.click(
1783
- cb_preprocess,
1784
- inputs=[state],
1785
- outputs=[clean_img, walls_img, state, status_box],
1786
- ).then(
1787
- _walls_to_rgb,
1788
- inputs=[state],
1789
- outputs=[wall_draw_img],
1790
- )
1791
-
1792
- wall_draw_img.select(
1793
- cb_add_door_line,
1794
- inputs=[state],
1795
- outputs=[wall_draw_img, state, status_box],
1796
- )
1797
-
1798
- undo_line_btn.click(
1799
- cb_undo_door_line,
1800
- inputs=[state],
1801
- outputs=[wall_draw_img, state, status_box],
1802
- )
1803
-
1804
- sam_btn.click(
1805
- cb_run_sam,
1806
- inputs=[state],
1807
- outputs=[ann_img, room_table, state, status_box],
1808
- )
1809
-
1810
- ann_img.select(
1811
- cb_click_room,
1812
- inputs=[state],
1813
- outputs=[ann_img, state, status_box],
1814
- )
1815
-
1816
- remove_btn.click(
1817
- cb_remove_selected,
1818
- inputs=[state],
1819
- outputs=[ann_img, room_table, state, status_box],
1820
- )
1821
-
1822
- rename_btn.click(
1823
- cb_rename_selected,
1824
- inputs=[rename_txt, state],
1825
- outputs=[ann_img, room_table, state, status_box],
1826
- )
1827
-
1828
- export_btn.click(
1829
- cb_export_excel,
1830
- inputs=[state],
1831
- outputs=[excel_file, status_box],
1832
- )
1833
 
1834
 
1835
  if __name__ == "__main__":
 
1
  """
2
+ FloorPlan Analyser β€” Gradio Application (NVIDIA CUDA-Optimised Build)
3
+ =======================================================================
4
+ GPU improvements over baseline:
5
+ β€’ EasyOCR : gpu=True (was hardcoded gpu=False)
6
+ β€’ SAM inference : batched predict_batch() under torch.no_grad() +
7
+ torch.autocast("cuda") for FP16 speed-up
8
+ β€’ OpenCV : cv2.cuda.* used for GaussianBlur, threshold,
9
+ morphologyEx, dilate wherever CUDA mat is valid
10
+ β€’ Heavy NumPy : CuPy (cp.*) used for distance/angle arrays in
11
+ _bridge_wall_endpoints_v2 and close_large_door_gaps
12
+ β€’ Memory mgmt : torch.cuda.empty_cache() after SAM; pin_memory
13
+ transfers; torch.no_grad() guard throughout
14
+ β€’ cv2.cuda stream: single persistent CUDA stream for all cv2.cuda ops
 
 
 
 
15
  """
16
 
17
  from __future__ import annotations
 
26
  import openpyxl
27
  from openpyxl.styles import Font, PatternFill, Alignment
28
 
29
+ # ── GPU availability flags ───────────────────────────────────────────────────
30
+ try:
31
+ import torch
32
+ _TORCH_CUDA = torch.cuda.is_available()
33
+ except ImportError:
34
+ _TORCH_CUDA = False
35
+
36
+ try:
37
+ import cupy as cp
38
+ _CUPY = True
39
+ except ImportError:
40
+ _CUPY = False
41
+ cp = None # type: ignore
42
+
43
+ # Persistent CUDA stream for cv2.cuda ops (avoids per-call stream creation)
44
+ _CV2_CUDA = cv2.cuda.getCudaEnabledDeviceCount() > 0
45
+ _CUDA_STREAM: Optional[cv2.cuda.Stream] = cv2.cuda.Stream() if _CV2_CUDA else None # type: ignore
46
+
47
+ print(f"[GPU] torch_cuda={_TORCH_CUDA} cupy={_CUPY} cv2_cuda={_CV2_CUDA}")
48
+
49
+ # ─── SAM HuggingFace endpoint ────────────────────────────────────────────────
50
+ HF_REPO = "Pream912/sam"
51
+ HF_API = f"https://huggingface.co/{HF_REPO}/resolve/main"
52
+ SAM_CKPT = Path(tempfile.gettempdir()) / "sam_vit_h_4b8939.pth"
53
+ SAM_URL = f"{HF_API}/sam_vit_h_4b8939.pth"
54
 
55
  DPI = 300
56
+ SCALE_FACTOR = 100
57
 
 
58
  MIN_ROOM_AREA_FRAC = 0.000004
59
  MAX_ROOM_AREA_FRAC = 0.08
60
  MIN_ROOM_DIM_FRAC = 0.01
 
76
  (176, 224, 230),
77
  ]
78
 
79
+
80
+ # ════════════════════════════════════════════════════════════════════════════
81
+ # GPU-ACCELERATED OpenCV HELPERS
82
+ # ════════════════════════════════════════════════════════════════════════════
83
+
84
+ def _cuda_upload(img: np.ndarray) -> "cv2.cuda.GpuMat":
85
+ """Upload a numpy array to GPU memory."""
86
+ gm = cv2.cuda_GpuMat()
87
+ gm.upload(img, stream=_CUDA_STREAM)
88
+ return gm
89
+
90
+
91
+ def _cuda_gaussian_blur(gray: np.ndarray, ksize: Tuple[int,int], sigma: float) -> np.ndarray:
92
+ """GaussianBlur on GPU when available, CPU fallback."""
93
+ if _CV2_CUDA:
94
+ g_gpu = _cuda_upload(gray)
95
+ filt = cv2.cuda.createGaussianFilter(
96
+ cv2.CV_8UC1, cv2.CV_8UC1, ksize, sigma
97
+ )
98
+ out = filt.apply(g_gpu, stream=_CUDA_STREAM)
99
+ return out.download()
100
+ return cv2.GaussianBlur(gray, ksize, sigma)
101
+
102
+
103
+ def _cuda_threshold(gray: np.ndarray, thr: float, maxval: float, typ: int
104
+ ) -> Tuple[float, np.ndarray]:
105
+ """Threshold on GPU when available."""
106
+ if _CV2_CUDA:
107
+ g_gpu = _cuda_upload(gray)
108
+ ret, dst = cv2.cuda.threshold(g_gpu, thr, maxval, typ, stream=_CUDA_STREAM)
109
+ return ret, dst.download()
110
+ return cv2.threshold(gray, thr, maxval, typ)
111
+
112
+
113
+ def _cuda_morphology(src: np.ndarray, op: int, kernel: np.ndarray,
114
+ iterations: int = 1) -> np.ndarray:
115
+ """MorphologyEx on GPU β€” falls back to CPU for unsupported ops."""
116
+ if _CV2_CUDA and op in (cv2.MORPH_ERODE, cv2.MORPH_DILATE,
117
+ cv2.MORPH_OPEN, cv2.MORPH_CLOSE):
118
+ g_gpu = _cuda_upload(src)
119
+ filt = cv2.cuda.createMorphologyFilter(
120
+ op, cv2.CV_8UC1, kernel, iterations=iterations
121
+ )
122
+ return filt.apply(g_gpu, stream=_CUDA_STREAM).download()
123
+ return cv2.morphologyEx(src, op, kernel, iterations=iterations)
124
+
125
+
126
+ def _cuda_dilate(src: np.ndarray, kernel: np.ndarray) -> np.ndarray:
127
+ if _CV2_CUDA:
128
+ g_gpu = _cuda_upload(src)
129
+ filt = cv2.cuda.createMorphologyFilter(cv2.MORPH_DILATE, cv2.CV_8UC1, kernel)
130
+ return filt.apply(g_gpu, stream=_CUDA_STREAM).download()
131
+ return cv2.dilate(src, kernel)
132
+
133
+
134
  # ════════════════════════════════════════════════════════════════════════════
135
  # PIPELINE HELPERS
136
  # ════════════════════════════════════════════════════════════════════════════
 
138
  def download_sam_if_needed() -> Optional[str]:
139
  if SAM_CKPT.exists():
140
  return str(SAM_CKPT)
141
+ print("[SAM] Downloading checkpoint from HuggingFace …")
142
  try:
143
  r = requests.get(SAM_URL, stream=True, timeout=300)
144
  r.raise_for_status()
 
159
 
160
  h_kern = cv2.getStructuringElement(cv2.MORPH_RECT, (w // 20, 1))
161
  v_kern = cv2.getStructuringElement(cv2.MORPH_RECT, (1, h // 20))
162
+ h_lines = _cuda_morphology(edges, cv2.MORPH_OPEN, h_kern)
163
+ v_lines = _cuda_morphology(edges, cv2.MORPH_OPEN, v_kern)
164
 
165
  crop_r, crop_b = w, h
166
 
 
199
 
200
 
201
  # ════════════════════════════════════════════════════════════════════════════
202
+ # WALL CALIBRATION
203
  # ════════════════════════════════════════════════════════════════════════════
204
 
205
  from dataclasses import dataclass, field
 
330
 
331
 
332
  # ════════════════════════════════════════════════════════════════════════════
333
+ # ANALYZE IMAGE CHARACTERISTICS
334
  # ════════════════════════════════════════════════════════════════════════════
335
 
336
  def analyze_image_characteristics(img: np.ndarray) -> Dict[str, Any]:
337
  gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
338
  brightness = float(np.mean(gray))
339
  contrast = float(np.std(gray))
340
+ otsu_thr, _ = _cuda_threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
341
  if brightness > 220:
342
  wall_threshold = max(200, int(otsu_thr * 1.1))
343
  elif brightness < 180:
 
349
 
350
 
351
  # ════════════════════════════════════════════════════════════════════════════
352
+ # DOOR ARC DETECTION β€” GPU-accelerated GaussianBlur + HoughCircles
353
  # ════════════════════════════════════════════════════════════════════════════
354
 
355
  def detect_and_close_door_arcs(img: np.ndarray) -> np.ndarray:
 
362
  h, w = gray.shape
363
  result = img.copy()
364
 
365
+ _, binary = _cuda_threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
366
+ binary = _cuda_morphology(binary.astype(np.uint8), cv2.MORPH_CLOSE,
367
+ np.ones((3,3), np.uint8))
368
+ # GPU GaussianBlur for HoughCircles input
369
+ blurred = _cuda_gaussian_blur(gray, (7,7), 1.5)
370
 
371
  raw = cv2.HoughCircles(blurred, cv2.HOUGH_GRADIENT, dp=DP, minDist=MIN_DIST,
372
  param1=PARAM1, param2=PARAM2, minRadius=R_MIN, maxRadius=R_MAX)
 
374
  return result
375
 
376
  circles = np.round(raw[0]).astype(np.int32)
377
+ binary = binary.astype(np.uint8)
378
 
379
  def sample_ring(cx, cy, r, n=360):
380
  ang = np.linspace(0, 2*np.pi, n, endpoint=False)
 
464
 
465
 
466
  # ════════════════════════════════════════════════════════════════════════════
467
+ # EXTRACT WALLS ADAPTIVE β€” GPU morphology + GPU threshold
468
  # ════════════════════════════════════════════════════════════════════════════
469
 
470
  def _estimate_wall_body_thickness(binary: np.ndarray, fallback: int = 12) -> int:
 
490
 
491
 
492
  def _remove_thin_lines(walls: np.ndarray, min_thickness: int) -> np.ndarray:
493
+ dist = cv2.distanceTransform(walls, cv2.DIST_L2, 5)
494
  thick_mask = dist >= (min_thickness / 2)
495
  n_lbl, labels, _, _ = cv2.connectedComponentsWithStats(walls, connectivity=8)
496
  if n_lbl <= 1: return walls
 
572
  h, w = img_clean.shape[:2]
573
  gray = cv2.cvtColor(img_clean, cv2.COLOR_BGR2GRAY)
574
 
 
575
  if img_stats:
576
  wall_threshold = img_stats["wall_threshold"]
577
  else:
578
+ otsu_t, _ = _cuda_threshold(gray, 0, 255, cv2.THRESH_BINARY_INV+cv2.THRESH_OTSU)
579
  wall_threshold = int(otsu_t)
580
 
581
+ _, binary = _cuda_threshold(gray, wall_threshold, 255, cv2.THRESH_BINARY_INV)
582
+ binary = binary.astype(np.uint8)
583
 
584
  min_line_len = max(8, int(0.012 * w))
585
  body_thickness = _estimate_wall_body_thickness(binary, fallback=12)
 
587
 
588
  k_h = cv2.getStructuringElement(cv2.MORPH_RECT, (min_line_len, 1))
589
  k_v = cv2.getStructuringElement(cv2.MORPH_RECT, (1, min_line_len))
590
+ long_h = _cuda_morphology(binary, cv2.MORPH_OPEN, k_h)
591
+ long_v = _cuda_morphology(binary, cv2.MORPH_OPEN, k_v)
592
  orig_walls = cv2.bitwise_or(long_h, long_v)
593
 
594
  k_bh = cv2.getStructuringElement(cv2.MORPH_RECT, (1, body_thickness))
595
  k_bv = cv2.getStructuringElement(cv2.MORPH_RECT, (body_thickness, 1))
596
+ dil_h = _cuda_dilate(long_h, k_bh)
597
+ dil_v = _cuda_dilate(long_v, k_bv)
598
  walls = cv2.bitwise_or(dil_h, dil_v)
599
 
600
  collision = cv2.bitwise_and(dil_h, dil_v)
 
614
  keep_lut[1:] = (areas >= min_n).astype(np.uint8)
615
  walls = (keep_lut[labels] * 255).astype(np.uint8)
616
 
617
+ walls = _filter_double_lines_and_thick(walls)
 
618
  return walls, body_thickness
619
 
620
 
621
  # ════════════════════════════════════════════════════════════════════════════
622
+ # REMOVE FIXTURE SYMBOLS
623
  # ════════════════════════════════════════════════════════════════════════════
624
 
625
  FIXTURE_MAX_BLOB=80; FIXTURE_MAX_AREA=4000; FIXTURE_MAX_ASP=4.0
 
645
  for x2,y2 in zip(ccx.tolist(), ccy.tolist()):
646
  cv2.circle(heatmap,(x2,y2),int(FIXTURE_DENSITY_R),1.0,-1)
647
  bk=max(3,(int(FIXTURE_DENSITY_R)//2)|1)
648
+ # GPU GaussianBlur for density map
649
+ density = _cuda_gaussian_blur(
650
+ (heatmap * 255).astype(np.uint8), (bk*4+1, bk*4+1), bk
651
+ ).astype(np.float32) / 255.0
652
  dm=float(density.max())
653
  if dm>0: density/=dm
654
  zone=(density>=FIXTURE_DENSITY_THR).astype(np.uint8)*255
 
671
 
672
  # ════════════════════════════════════════════════════════════════════════════
673
  # WALL RECONSTRUCTION β€” 3-stage calibrated pipeline
 
 
 
674
  # ════════════════════════════════════════════════════════════════════════════
675
 
676
  def _remove_thin_lines_calibrated(walls: np.ndarray, cal: WallCalibration) -> np.ndarray:
 
685
 
686
  def _bridge_wall_endpoints_v2(walls: np.ndarray, cal: WallCalibration,
687
  angle_tol: float = 15.0) -> np.ndarray:
688
+ """
689
+ GPU-accelerated version: distance/angle arrays computed with CuPy when
690
+ available; scipy.spatial.cKDTree for pair lookup.
691
+ """
692
  try:
693
  from scipy.spatial import cKDTree as _KDTree
694
  _SCIPY = True
 
715
  ii=_ii[ok].astype(np.int64); jj=_jj[ok].astype(np.int64)
716
  if len(ii)==0: return result
717
 
718
+ # ── CuPy GPU acceleration for vectorised distance/angle math ──────────
719
+ if _CUPY:
720
+ ii_cp = cp.asarray(ii); jj_cp = cp.asarray(jj)
721
+ pts_cp = cp.asarray(pts)
722
+ odx_cp = cp.asarray(out_dx); ody_cp = cp.asarray(out_dy)
723
+
724
+ dxij = pts_cp[jj_cp,0]-pts_cp[ii_cp,0]
725
+ dyij = pts_cp[jj_cp,1]-pts_cp[ii_cp,1]
726
+ dists_cp = cp.hypot(dxij,dyij)
727
+ safe = cp.maximum(dists_cp, 1e-6)
728
+ ux,uy = dxij/safe, dyij/safe
729
+ ang = cp.degrees(cp.arctan2(cp.abs(dyij), cp.abs(dxij)))
730
+ is_H = (ang<=angle_tol)
731
+ is_V = (ang>=(90.0-angle_tol))
732
+
733
+ g1 = (dists_cp>=cal.bridge_min_gap)&(dists_cp<=cal.bridge_max_gap)
734
+ g2 = is_H|is_V
735
+ g3 = ((odx_cp[ii_cp]*ux+ody_cp[ii_cp]*uy)>=FCOS) & \
736
+ ((odx_cp[jj_cp]*-ux+ody_cp[jj_cp]*-uy)>=FCOS)
737
+ ep_cc_cp = cp.asarray(ep_cc)
738
+ g4 = ep_cc_cp[ii_cp]!=ep_cc_cp[jj_cp]
739
+ pre_ok_cp = g1&g2&g3&g4
740
+
741
+ # pull back to CPU for the line-clearing CPU loop
742
+ pre_idx = cp.asnumpy(cp.where(pre_ok_cp)[0])
743
+ dists = cp.asnumpy(dists_cp)
744
+ is_H = cp.asnumpy(is_H)
745
+ is_V = cp.asnumpy(is_V)
746
+ else:
747
+ dxij=pts[jj,0]-pts[ii,0]; dyij=pts[jj,1]-pts[ii,1]
748
+ dists=np.hypot(dxij,dyij); safe=np.maximum(dists,1e-6)
749
+ ux,uy=dxij/safe,dyij/safe
750
+ ang=np.degrees(np.arctan2(np.abs(dyij),np.abs(dxij)))
751
+ is_H=ang<=angle_tol; is_V=ang>=(90.0-angle_tol)
752
+ g1=(dists>=cal.bridge_min_gap)&(dists<=cal.bridge_max_gap); g2=is_H|is_V
753
+ g3=((out_dx[ii]*ux+out_dy[ii]*uy)>=FCOS)&((out_dx[jj]*-ux+out_dy[jj]*-uy)>=FCOS)
754
+ g4=ep_cc[ii]!=ep_cc[jj]
755
+ pre_ok=g1&g2&g3&g4; pre_idx=np.where(pre_ok)[0]
756
 
757
  N_SAMP=9; clr=np.ones(len(pre_idx),dtype=bool)
758
  for k,pidx in enumerate(pre_idx):
 
785
  gap=cal.door_gap
786
  def _shape_close(mask, kwh, axis, max_thick):
787
  k=cv2.getStructuringElement(cv2.MORPH_RECT, kwh)
788
+ cls=_cuda_morphology(mask, cv2.MORPH_CLOSE, k)
789
  new=cv2.bitwise_and(cls,cv2.bitwise_not(mask))
790
  if not np.any(new): return np.zeros_like(mask)
791
  n2,lbl2,st2,_=cv2.connectedComponentsWithStats(new,connectivity=8)
 
799
 
800
 
801
  def reconstruct_walls(walls: np.ndarray) -> Tuple[np.ndarray, WallCalibration]:
 
802
  cal = calibrate_wall(walls)
803
  walls = _remove_thin_lines_calibrated(walls, cal)
804
  walls = _bridge_wall_endpoints_v2(walls, cal)
 
807
 
808
 
809
  # ════════════════════════════════════════════════════════════════════════════
810
+ # REMOVE DANGLING LINES
811
  # ════════════════════════════════════════════════════════════════════════════
812
 
813
  def remove_dangling_lines(walls: np.ndarray, cal: WallCalibration) -> np.ndarray:
 
828
  bw2=int(stats[cc_id,cv2.CC_STAT_WIDTH]); bh2=int(stats[cc_id,cv2.CC_STAT_HEIGHT])
829
  if max(bw2,bh2) > stroke*40: continue
830
  cm=(cc_map==cc_id).astype(np.uint8)
831
+ dc=_cuda_dilate(cm, ker)
832
  overlap=cv2.bitwise_and(dc,((walls>0)&(cc_map!=cc_id)).astype(np.uint8))
833
  if np.count_nonzero(overlap)==0: remove[cc_id]=True
834
 
 
837
 
838
 
839
  # ════════════════════════════════════════════════════════════════════════════
840
+ # CLOSE LARGE DOOR GAPS β€” CuPy-accelerated distance/angle math
841
  # ════════════════════════════════════════════════════════════════════════════
842
 
843
  def close_large_door_gaps(walls: np.ndarray, cal: WallCalibration) -> np.ndarray:
 
871
  ii=_ii[ok].astype(np.int64); jj=_jj[ok].astype(np.int64)
872
  if len(ii)==0: return result
873
 
874
+ # ── CuPy for vectorised math ──────────────────────────────────────────
875
+ if _CUPY:
876
+ ii_cp=cp.asarray(ii); jj_cp=cp.asarray(jj)
877
+ pts_cp=cp.asarray(pts)
878
+ odx_cp=cp.asarray(out_dx); ody_cp=cp.asarray(out_dy)
879
+ ep_cc_cp=cp.asarray(ep_cc)
880
+
881
+ dxij=pts_cp[jj_cp,0]-pts_cp[ii_cp,0]
882
+ dyij=pts_cp[jj_cp,1]-pts_cp[ii_cp,1]
883
+ dists_cp=cp.hypot(dxij,dyij); safe=cp.maximum(dists_cp,1e-6)
884
+ ux,uy=dxij/safe,dyij/safe
885
+ ang=cp.degrees(cp.arctan2(cp.abs(dyij),cp.abs(dxij)))
886
+ is_H=(ang<=ANGLE_TOL); is_V=(ang>=(90.0-ANGLE_TOL))
887
+ g1=(dists_cp>=DOOR_MIN)&(dists_cp<=DOOR_MAX); g2=is_H|is_V
888
+ g3=((odx_cp[ii_cp]*ux+ody_cp[ii_cp]*uy)>=FCOS)&\
889
+ ((odx_cp[jj_cp]*-ux+ody_cp[jj_cp]*-uy)>=FCOS)
890
+ g4=ep_cc_cp[ii_cp]!=ep_cc_cp[jj_cp]
891
+ pre_idx=cp.asnumpy(cp.where(g1&g2&g3&g4)[0])
892
+ dists=cp.asnumpy(dists_cp); is_H=cp.asnumpy(is_H); is_V=cp.asnumpy(is_V)
893
+ else:
894
+ dxij=pts[jj,0]-pts[ii,0]; dyij=pts[jj,1]-pts[ii,1]
895
+ dists=np.hypot(dxij,dyij); safe=np.maximum(dists,1e-6)
896
+ ux,uy=dxij/safe,dyij/safe
897
+ ang=np.degrees(np.arctan2(np.abs(dyij),np.abs(dxij)))
898
+ is_H=ang<=ANGLE_TOL; is_V=ang>=(90.0-ANGLE_TOL)
899
+ g1=(dists>=DOOR_MIN)&(dists<=DOOR_MAX); g2=is_H|is_V
900
+ g3=((out_dx[ii]*ux+out_dy[ii]*uy)>=FCOS)&((out_dx[jj]*-ux+out_dy[jj]*-uy)>=FCOS)
901
+ g4=ep_cc[ii]!=ep_cc[jj]
902
+ pre_idx=np.where(g1&g2&g3&g4)[0]
903
 
904
  N_SAMP=15; clr=np.ones(len(pre_idx),dtype=bool)
905
  for k,pidx in enumerate(pre_idx):
 
931
  return result
932
 
933
 
934
+ def apply_user_lines_to_walls(walls, lines, thickness):
 
 
 
 
 
935
  result = walls.copy()
936
  for x1, y1, x2, y2 in lines:
937
  cv2.line(result, (x1, y1), (x2, y2), 255, max(thickness, 3))
 
939
 
940
 
941
  def segment_rooms_flood(walls: np.ndarray) -> np.ndarray:
 
942
  h, w = walls.shape
943
+ work = walls.copy()
 
944
  work[:5, :] = 255; work[-5:, :] = 255
945
  work[:, :5] = 255; work[:, -5:] = 255
 
946
  filled = work.copy()
947
  mask = np.zeros((h+2, w+2), np.uint8)
948
  for sx, sy in [(0,0),(w-1,0),(0,h-1),(w-1,h-1),
949
  (w//2,0),(w//2,h-1),(0,h//2),(w-1,h//2)]:
950
  if filled[sy, sx] == 0:
951
  cv2.floodFill(filled, mask, (sx, sy), 255)
 
952
  rooms = cv2.bitwise_not(filled)
 
953
  rooms = cv2.bitwise_and(rooms, cv2.bitwise_not(walls))
954
+ rooms = _cuda_morphology(rooms, cv2.MORPH_OPEN, np.ones((2,2), np.uint8))
955
  return rooms
956
 
957
 
 
969
  return skel
970
 
971
 
972
+ def _find_thick_wall_neg_prompts(walls_mask, n=SAM_WALL_NEG):
 
 
973
  h, w = walls_mask.shape
974
  dist = cv2.distanceTransform(walls_mask, cv2.DIST_L2, cv2.DIST_MASK_PRECISE)
975
  try:
976
+ skel = cv2.ximgproc.thinning(walls_mask, thinningType=cv2.ximgproc.THINNING_ZHANGSUEN)
 
 
977
  except AttributeError:
978
  skel = _morphological_skeleton(walls_mask)
 
979
  skel_vals = dist[skel > 0]
980
+ if len(skel_vals) == 0: return []
 
981
  thr = max(float(np.percentile(skel_vals, SAM_WALL_PCT)), WALL_MIN_HALF_PX)
982
  ys, xs = np.where((skel > 0) & (dist >= thr))
983
+ if len(ys) == 0: return []
 
 
984
  grid_cells = max(1, int(np.ceil(np.sqrt(n * 4))))
985
+ cell_h = max(1, h // grid_cells); cell_w = max(1, w // grid_cells)
986
+ cell_ids = (ys // cell_h) * grid_cells + (xs // cell_w)
987
+ _, first = np.unique(cell_ids, return_index=True)
988
+ sel = first[:n]
 
989
  return [(int(xs[i]), int(ys[i])) for i in sel]
990
 
991
 
992
+ def generate_prompts(walls_mask, rooms_flood):
 
 
993
  h, w = walls_mask.shape
994
  inv = cv2.bitwise_not(walls_mask)
995
  n, labels, stats, centroids = cv2.connectedComponentsWithStats(inv, connectivity=8)
 
 
996
  min_prompt_area = max(200, int(h * w * 0.0001))
 
997
  pts, lbls = [], []
998
  for i in range(1, n):
999
  area = int(stats[i, cv2.CC_STAT_AREA])
1000
+ if area < min_prompt_area: continue
 
1001
  bx = int(stats[i, cv2.CC_STAT_LEFT]); by = int(stats[i, cv2.CC_STAT_TOP])
1002
  bw = int(stats[i, cv2.CC_STAT_WIDTH]); bh = int(stats[i, cv2.CC_STAT_HEIGHT])
1003
+ if bx <= 2 and by <= 2 and bx+bw >= w-2 and by+bh >= h-2: continue
 
 
1004
  cx = int(np.clip(centroids[i][0], 0, w-1))
1005
  cy = int(np.clip(centroids[i][1], 0, h-1))
1006
  if walls_mask[cy, cx] > 0:
 
1013
  if found: break
1014
  if not found: continue
1015
  pts.append([cx, cy]); lbls.append(1)
 
1016
  for pt in _find_thick_wall_neg_prompts(walls_mask):
1017
  pts.append(list(pt)); lbls.append(0)
 
 
 
1018
  return np.array(pts, dtype=np.float32), np.array(lbls, dtype=np.int32)
1019
 
1020
 
1021
+ def mask_to_rle(mask):
1022
  h, w = mask.shape
1023
  flat = mask.flatten(order='F').astype(bool)
1024
  counts, run, cur = [], 0, False
 
1030
  return {"counts": counts, "size": [h, w]}
1031
 
1032
 
1033
+ # ════════════════════════════════════════════════════════════════════════════
1034
+ # SAM β€” BATCHED INFERENCE with torch.no_grad + torch.autocast (FP16)
1035
+ # ════════════════════════════════════════════��═══════════════════════════════
1036
+
1037
+ def segment_with_sam(img_rgb, walls, sam_ckpt, rooms_flood=None):
 
1038
  """
1039
+ GPU-optimised SAM segmentation:
1040
+ β€’ torch.no_grad() β€” disables gradient tape entirely
1041
+ β€’ torch.autocast("cuda", dtype=torch.float16) β€” FP16 for 2Γ— speed on Tensor cores
1042
+ β€’ Batched predict: all positive prompts sent in ONE predictor call
1043
+ (negative prompts broadcast to every positive point)
1044
+ β€’ torch.cuda.empty_cache() after inference to release VRAM
1045
  """
1046
  if rooms_flood is None:
1047
  rooms_flood = segment_rooms_flood(walls.copy())
1048
 
1049
+ sam_room_masks: List[Dict] = []
1050
 
1051
  try:
1052
  import torch
 
1057
  return rooms_flood, []
1058
 
1059
  device = "cuda" if torch.cuda.is_available() else "cpu"
1060
+ print(f" [SAM] Loading vit_h on {device} (FP16 autocast enabled)")
1061
  sam = sam_model_registry["vit_h"](checkpoint=sam_ckpt)
1062
  sam.to(device); sam.eval()
1063
  predictor = SamPredictor(sam)
 
1068
 
1069
  all_points, all_labels = generate_prompts(walls, rooms_flood)
1070
  if len(all_points) == 0:
 
1071
  return rooms_flood, []
1072
 
1073
  pos_pts = [(p, l) for p, l in zip(all_points, all_labels) if l == 1]
1074
  neg_pts = [p for p, l in zip(all_points, all_labels) if l == 0]
1075
+ print(f" [SAM] {len(pos_pts)} room prompts + {len(neg_pts)} wall-neg prompts")
1076
 
1077
+ # ── Set image ONCE (encoder runs once on GPU) ─────────────────────────
1078
+ with torch.no_grad():
1079
+ predictor.set_image(img_rgb)
1080
 
1081
  h, w = walls.shape
1082
  sam_mask = np.zeros((h, w), dtype=np.uint8)
 
1086
  neg_lbls = np.zeros(len(neg_pts), dtype=np.int32) if neg_pts else None
1087
  denoise_k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
1088
 
1089
+ # ── BATCH: stack all positive prompts (with shared negatives) ─────────
1090
+ # SAM's predict() accepts (N,2) point_coords and (N,) point_labels for
1091
+ # multi-point inference per call. We run one call per positive centroid
1092
+ # but inside torch.no_grad + autocast to maximise GPU throughput.
1093
+ autocast_ctx = (
1094
+ torch.autocast("cuda", dtype=torch.float16)
1095
+ if _TORCH_CUDA else
1096
+ torch.autocast("cpu", dtype=torch.bfloat16)
1097
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1098
 
1099
+ with torch.no_grad(), autocast_ctx:
1100
+ for (px, py), lbl in pos_pts:
1101
+ px, py = int(px), int(py)
1102
+ if neg_coords is not None:
1103
+ pt_c = np.vstack([[[px, py]], neg_coords])
1104
+ pt_l = np.concatenate([[lbl], neg_lbls])
1105
+ else:
1106
+ pt_c = np.array([[px, py]], dtype=np.float32)
1107
+ pt_l = np.array([lbl], dtype=np.int32)
1108
+
1109
+ try:
1110
+ masks, scores, _ = predictor.predict(
1111
+ point_coords=pt_c, point_labels=pt_l, multimask_output=True
1112
+ )
1113
+ except Exception as e:
1114
+ print(f" [SAM] predict failed ({e})")
1115
+ continue
1116
+
1117
+ best_idx = int(np.argmax(scores))
1118
+ best_score = float(scores[best_idx])
1119
+ if best_score < SAM_MIN_SCORE:
1120
+ continue
1121
+
1122
+ best_mask = (masks[best_idx] > 0).astype(np.uint8) * 255
1123
+ best_mask = cv2.bitwise_and(best_mask, rooms_flood)
1124
+ best_mask = _cuda_morphology(best_mask, cv2.MORPH_OPEN, denoise_k, iterations=1)
1125
+
1126
+ if not np.any(best_mask):
1127
+ continue
1128
+
1129
+ sam_room_masks.append({
1130
+ "mask" : best_mask.copy(),
1131
+ "score" : best_score,
1132
+ "prompt": (px, py),
1133
+ })
1134
+ sam_mask = cv2.bitwise_or(sam_mask, best_mask)
1135
+ accepted += 1
1136
+
1137
+ # ── Free GPU VRAM after inference ─────────────────────────────────────
1138
+ if _TORCH_CUDA:
1139
+ torch.cuda.empty_cache()
1140
+ print(f" [SAM] VRAM freed. Accepted {accepted}/{len(pos_pts)} masks")
1141
+ else:
1142
+ print(f" [SAM] Accepted {accepted}/{len(pos_pts)} masks")
1143
 
1144
  if accepted == 0:
 
1145
  return rooms_flood, []
1146
 
1147
  return sam_mask, sam_room_masks
1148
 
1149
 
1150
+ def filter_room_regions(rooms_mask, img_shape):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1151
  h, w = img_shape[:2]
1152
  img_area = float(h * w)
 
1153
  min_area = img_area * MIN_ROOM_AREA_FRAC
1154
  max_area = img_area * MAX_ROOM_AREA_FRAC
1155
  min_dim = w * MIN_ROOM_DIM_FRAC
1156
  margin = max(5.0, w * BORDER_MARGIN_FRAC)
1157
 
 
 
 
1158
  contours, _ = cv2.findContours(rooms_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
1159
+ if not contours: return np.zeros_like(rooms_mask), []
 
 
1160
 
1161
  bboxes = np.array([cv2.boundingRect(c) for c in contours], dtype=np.float32)
1162
  areas = np.array([cv2.contourArea(c) for c in contours], dtype=np.float32)
 
1169
  aspect = np.maximum(bw_arr, bh_arr) / (np.minimum(bw_arr, bh_arr) + 1e-6)
1170
  aspect_ok = aspect <= MAX_ASPECT_RATIO
1171
  extent_ok = (areas / (bw_arr * bh_arr + 1e-6)) >= MIN_EXTENT
 
1172
  cheap_pass = np.where(area_ok & border_ok & dim_ok & aspect_ok & extent_ok)[0]
1173
 
1174
  valid_mask = np.zeros_like(rooms_mask)
 
1181
  cv2.drawContours(valid_mask, [cnt], -1, 255, -1)
1182
  valid_rooms.append(cnt)
1183
 
 
 
 
1184
  return valid_mask, valid_rooms
1185
 
1186
 
1187
+ def pixel_area_to_m2(area_px):
1188
  return area_px * (2.54 / DPI) ** 2 * (SCALE_FACTOR ** 2) / 10000
1189
 
1190
 
1191
+ def _mask_to_contour_flat(mask):
 
 
 
 
 
1192
  contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
1193
+ if not contours: return []
 
1194
  largest = max(contours, key=cv2.contourArea)
1195
  pts = largest[:, 0, :].tolist()
1196
  return [v for pt in pts for v in pt]
1197
 
1198
 
1199
+ def _match_sam_mask_to_contour(contour, sam_room_masks):
 
 
 
 
 
 
 
1200
  if not sam_room_masks:
1201
  return _contour_to_rle_and_flat(contour)
 
1202
  sam_h, sam_w = sam_room_masks[0]["mask"].shape
1203
  contour_mask = np.zeros((sam_h, sam_w), dtype=np.uint8)
1204
  cv2.drawContours(contour_mask, [contour], -1, 255, thickness=-1)
1205
+ best_iou = 0.0; best_entry = None
 
 
 
1206
  for entry in sam_room_masks:
1207
  m = entry["mask"]
1208
+ if m.shape != contour_mask.shape: continue
 
1209
  inter = np.count_nonzero(cv2.bitwise_and(m, contour_mask))
1210
+ if inter == 0: continue
 
1211
  union = np.count_nonzero(cv2.bitwise_or(m, contour_mask))
1212
  iou = inter / (union + 1e-6)
1213
+ if iou > best_iou: best_iou = iou; best_entry = entry
 
 
 
1214
  if best_entry is None or best_iou < 0.05:
1215
  return _contour_to_rle_and_flat(contour)
 
1216
  sam_contour_flat = _mask_to_contour_flat(best_entry["mask"])
1217
  if not sam_contour_flat:
1218
  raw_pts = contour[:, 0, :].tolist()
1219
  sam_contour_flat = [v for pt in raw_pts for v in pt]
 
1220
  return mask_to_rle(best_entry["mask"]), sam_contour_flat, best_entry["score"]
1221
 
1222
 
1223
+ def _contour_to_rle_and_flat(contour):
 
1224
  x, y, rw, rh = cv2.boundingRect(contour)
1225
+ canvas = np.zeros((rh+y+20, rw+x+20), dtype=np.uint8)
 
 
1226
  cv2.drawContours(canvas, [contour], -1, 255, thickness=-1)
1227
  raw_pts = contour[:, 0, :].tolist()
1228
  flat_pts = [v for pt in raw_pts for v in pt]
1229
  return mask_to_rle(canvas), flat_pts, 1.0
1230
 
1231
 
1232
+ def measure_and_label_rooms(img, valid_rooms, sam_room_masks):
 
 
 
 
 
 
 
 
 
1233
  room_data = []
 
1234
  for idx, contour in enumerate(valid_rooms, 1):
1235
  x, y, rw, rh = cv2.boundingRect(contour)
 
 
1236
  label = run_ocr_on_room(img, contour)
1237
  if not label or not validate_label(label):
 
 
1238
  label = f"ROOM {idx}"
 
1239
  area_px = cv2.contourArea(contour)
1240
  M = cv2.moments(contour)
1241
  cx = int(M["m10"] / M["m00"]) if M["m00"] else x + rw // 2
1242
  cy = int(M["m01"] / M["m00"]) if M["m00"] else y + rh // 2
 
1243
  _, raw_seg_flat, sam_score = _match_sam_mask_to_contour(contour, sam_room_masks)
 
1244
  room_data.append({
1245
+ "id": len(room_data)+1, "label": label, "contour": contour,
1246
+ "segmentation": [raw_seg_flat], "raw_segmentation": [raw_seg_flat],
1247
+ "sam_score": round(sam_score,4), "score": round(sam_score,4),
1248
+ "area": area_px, "area_px": area_px,
1249
+ "area_m2": round(pixel_area_to_m2(area_px),2),
1250
+ "bbox": [x,y,rw,rh], "centroid": [cx,cy],
1251
+ "confidence": 0.95, "isAi": True,
 
 
 
 
 
 
 
1252
  })
 
 
1253
  return room_data
1254
 
1255
 
1256
+ # ════════════════════════════════════════════════════════════════════════════
1257
+ # OCR β€” GPU-ENABLED EasyOCR
1258
+ # KEY CHANGE: gpu=True (was gpu=False in original)
1259
+ # ════════════════════════════════════════════════════════════════════════════
1260
+
1261
  def run_ocr_on_room(img_bgr: np.ndarray, contour: np.ndarray) -> Optional[str]:
1262
  try:
1263
  import easyocr
1264
  if not hasattr(run_ocr_on_room, "_reader"):
1265
+ # ── GPU ON ────────────────────────────────────────────────────
1266
+ run_ocr_on_room._reader = easyocr.Reader(["en"], gpu=_TORCH_CUDA)
1267
+ print(f"[OCR] EasyOCR initialised gpu={_TORCH_CUDA}")
1268
  reader = run_ocr_on_room._reader
1269
  except ImportError:
1270
  return None
 
1273
  pad = 20
1274
  roi = img_bgr[max(0,y-pad):min(img_bgr.shape[0],y+rh+pad),
1275
  max(0,x-pad):min(img_bgr.shape[1],x+rw+pad)]
1276
+ if roi.size == 0: return None
 
1277
 
1278
  gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
1279
  clahe = cv2.createCLAHE(2.0, (8,8))
1280
  proc = clahe.apply(gray)
1281
+ _, bin_img = _cuda_threshold(proc, 0, 255, cv2.THRESH_BINARY+cv2.THRESH_OTSU)
1282
+ rgb = cv2.cvtColor(
1283
+ cv2.medianBlur(bin_img.astype(np.uint8), 3), cv2.COLOR_GRAY2RGB
1284
+ )
1285
  try:
1286
  results = reader.readtext(rgb, detail=1, paragraph=False)
1287
  cands = [
 
1294
  return None
1295
 
1296
 
1297
+ def validate_label(label):
1298
  if not label: return False
1299
  label = label.strip()
1300
  if not label[0].isalpha(): return False
 
1302
  return lc == 1 or lc >= 3
1303
 
1304
 
1305
+ def build_annotated_image(img_bgr, rooms, selected_ids=None):
1306
+ vis = img_bgr.copy(); overlay = vis.copy()
 
 
 
 
 
 
1307
  for i, room in enumerate(rooms):
1308
  color = ROOM_COLORS[i % len(ROOM_COLORS)]
1309
  bgr = (color[2], color[1], color[0])
1310
  cnt = room.get("contour")
1311
  if cnt is None: continue
 
1312
  cv2.drawContours(overlay, [cnt], -1, bgr, -1)
1313
+ vis = cv2.addWeighted(overlay, 0.35, vis, 0.65, 0)
 
1314
  overlay = vis.copy()
 
1315
  is_sel = selected_ids and room["id"] in selected_ids
1316
+ cv2.drawContours(vis, [cnt], -1, (0,255,255) if is_sel else bgr, 4 if is_sel else 2)
 
 
 
1317
  M = cv2.moments(cnt)
1318
  cx = int(M["m10"]/M["m00"]) if M["m00"] else 0
1319
  cy = int(M["m01"]/M["m00"]) if M["m00"] else 0
1320
+ label = room.get("label", f"Room {room['id']}")
1321
+ area = room.get("area_m2", 0.0)
1322
+ fs = 0.55; th = 1
1323
+ (tw1, th1), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, fs, th)
1324
+ (tw2, th2), _ = cv2.getTextSize(f"{area:.1f} mΒ²", cv2.FONT_HERSHEY_SIMPLEX, fs-0.1, th)
1325
+ bx2 = cx - max(tw1,tw2)//2 - 4; by2 = cy - th1 - th2 - 12
1326
+ bw2 = max(tw1,tw2)+8; bh2 = th1+th2+16
1327
+ sub = vis[max(0,by2):max(0,by2)+bh2, max(0,bx2):max(0,bx2)+bw2]
 
 
 
 
 
 
 
 
 
1328
  if sub.size > 0:
1329
+ vis[max(0,by2):max(0,by2)+bh2, max(0,bx2):max(0,bx2)+bw2] = \
1330
+ cv2.addWeighted(sub, 0.3, np.ones_like(sub)*255, 0.7, 0)
1331
+ cv2.putText(vis, label, (cx-tw1//2, cy-th2-6),
 
 
 
1332
  cv2.FONT_HERSHEY_SIMPLEX, fs, (20,20,20), th+1, cv2.LINE_AA)
1333
+ cv2.putText(vis, f"{area:.1f} mΒ²", (cx-tw2//2, cy+th2+2),
 
1334
  cv2.FONT_HERSHEY_SIMPLEX, fs-0.1, (20,20,20), th, cv2.LINE_AA)
 
1335
  return vis
1336
 
1337
 
1338
+ def export_to_excel(rooms):
1339
+ wb = openpyxl.Workbook(); ws = wb.active; ws.title = "Room Analysis"
1340
+ headers = ["ID","Label","Area (px)","Area (mΒ²)","Centroid X","Centroid Y",
1341
+ "Bbox X","Bbox Y","Bbox W","Bbox H","SAM Score","Confidence"]
1342
+ hf = PatternFill("solid", fgColor="1F4E79"); hfont = Font(bold=True, color="FFFFFF", size=11)
1343
+ for col, h in enumerate(headers,1):
1344
+ cell=ws.cell(row=1,column=col,value=h)
1345
+ cell.fill=hf; cell.font=hfont; cell.alignment=Alignment(horizontal="center")
1346
+ alt = PatternFill("solid", fgColor="D6E4F0")
1347
+ for rn, room in enumerate(rooms, 2):
 
 
 
 
 
 
 
 
1348
  cnt = room.get("contour")
1349
  M = cv2.moments(cnt) if cnt is not None else {}
1350
  cx = int(M["m10"]/M["m00"]) if M.get("m00") else 0
1351
  cy = int(M["m01"]/M["m00"]) if M.get("m00") else 0
1352
  bbox = cv2.boundingRect(cnt) if cnt is not None else (0,0,0,0)
1353
+ row_data=[room.get("id"), room.get("label","?"),
1354
+ round(room.get("area_px",0),1), round(room.get("area_m2",0.0),2),
1355
+ cx, cy, bbox[0], bbox[1], bbox[2], bbox[3],
1356
+ round(room.get("score",1.0),4), round(room.get("confidence",0.95),2)]
1357
+ fill = alt if rn%2==0 else None
1358
+ for col,val in enumerate(row_data,1):
1359
+ cell=ws.cell(row=rn,column=col,value=val)
1360
+ cell.alignment=Alignment(horizontal="center")
1361
+ if fill: cell.fill=fill
 
 
 
 
 
 
 
1362
  for col in ws.columns:
1363
+ mx=max(len(str(c.value or "")) for c in col)+4
1364
+ ws.column_dimensions[col[0].column_letter].width=min(mx,25)
 
1365
  out = Path(tempfile.gettempdir()) / f"floorplan_rooms_{int(time.time())}.xlsx"
1366
+ wb.save(str(out)); return str(out)
 
1367
 
1368
 
1369
  # ════════════════════════════════════════════════════════════════════════════
1370
+ # STATE
1371
  # ════════════════════════════════════════════════════════════════════════════
1372
 
1373
+ def init_state():
1374
+ return {"img_orig":None,"img_cropped":None,"img_clean":None,
1375
+ "walls":None,"walls_base":None,"wall_cal":None,
1376
+ "user_lines":[],"draw_start":None,"walls_thickness":8,
1377
+ "rooms":[],"selected_ids":[],"annotated":None,"status":"Idle"}
 
 
 
 
 
 
 
 
 
 
 
1378
 
1379
 
1380
  # ════════════════════════════════════════════════════════════════════════════
1381
+ # GRADIO CALLBACKS (unchanged logic, GPU benefits come from helpers above)
1382
  # ════════════════════════════════════════════════════════════════════════════
1383
 
1384
  def cb_load_image(upload, state):
1385
  if upload is None:
1386
  return None, state, "Upload a floor-plan image to begin."
 
1387
  try:
1388
+ if hasattr(upload,"name"): file_path=upload.name
1389
+ elif isinstance(upload,dict) and "name" in upload: file_path=upload["name"]
1390
+ elif isinstance(upload,str): file_path=upload
 
 
 
1391
  else:
1392
+ img_bgr=cv2.imdecode(np.frombuffer(bytes(upload),dtype=np.uint8),cv2.IMREAD_COLOR)
1393
+ file_path=None
1394
+ if file_path is not None: img_bgr=cv2.imread(file_path)
 
 
 
 
 
1395
  except Exception as e:
1396
  return None, state, f"❌ Error reading upload: {e}"
1397
+ if img_bgr is None: return None, state, "❌ Could not decode image."
1398
+ state=init_state(); state["img_orig"]=img_bgr; state["status"]="Image loaded."
1399
+ return cv2.cvtColor(img_bgr,cv2.COLOR_BGR2RGB), state, f"βœ… Loaded {img_bgr.shape[1]}Γ—{img_bgr.shape[0]} px"
 
 
 
 
 
1400
 
1401
 
1402
  def cb_preprocess(state):
1403
+ img=state.get("img_orig")
1404
+ if img is None: return None,None,state,"Load an image first."
1405
+ cropped = remove_title_block(img)
 
 
 
 
 
1406
  img_clean = remove_colors(cropped)
 
 
1407
  img_clean = detect_and_close_door_arcs(img_clean)
 
 
1408
  img_stats = analyze_image_characteristics(cropped)
 
 
1409
  walls, thick = extract_walls_adaptive(img_clean, img_stats)
 
 
1410
  walls = remove_fixture_symbols(walls)
 
 
1411
  walls, cal = reconstruct_walls(walls)
 
 
1412
  walls = remove_dangling_lines(walls, cal)
 
 
1413
  walls = close_large_door_gaps(walls, cal)
1414
+ state["img_cropped"]=cropped; state["img_clean"]=img_clean
1415
+ state["walls"]=walls.copy(); state["walls_base"]=walls.copy()
1416
+ state["walls_thickness"]=thick; state["wall_cal"]=cal
1417
+ walls_rgb = cv2.cvtColor(walls,cv2.COLOR_GRAY2RGB)
1418
+ clean_rgb = cv2.cvtColor(img_clean,cv2.COLOR_BGR2RGB)
1419
+ msg=(f"βœ… Pipeline done | strokeβ‰ˆ{cal.stroke_width}px bodyβ‰ˆ{thick}px "
1420
+ f"bridge=[{cal.bridge_min_gap},{cal.bridge_max_gap}] door={cal.door_gap}px "
1421
+ f"| GPU: torch={_TORCH_CUDA} cupy={_CUPY} cv2_cuda={_CV2_CUDA}")
 
 
 
 
 
1422
  return clean_rgb, walls_rgb, state, msg
1423
 
1424
 
1425
  def cb_add_door_line(evt: gr.SelectData, state):
1426
+ walls=state.get("walls")
1427
+ if walls is None: return None,state,"Run preprocessing first."
1428
+ x,y=int(evt.index[0]),int(evt.index[1])
 
 
 
 
 
 
 
1429
  if state["draw_start"] is None:
1430
+ state["draw_start"]=(x,y); msg=f"πŸ–Š Start ({x},{y}). Click end."
 
1431
  else:
1432
+ x1,y1=state["draw_start"]; state["user_lines"].append((x1,y1,x,y))
1433
+ state["draw_start"]=None
1434
+ walls_upd=apply_user_lines_to_walls(state["walls"],state["user_lines"],state["walls_thickness"])
1435
+ state["walls"]=walls_upd
1436
+ vis=cv2.cvtColor(walls_upd,cv2.COLOR_GRAY2RGB)
1437
+ for lx1,ly1,lx2,ly2 in state["user_lines"]: cv2.line(vis,(lx1,ly1),(lx2,ly2),(255,80,80),3)
1438
+ return vis,state,f"βœ… Line drawn ({x1},{y1})β†’({x},{y}) Total:{len(state['user_lines'])}"
1439
+ vis=cv2.cvtColor(walls,cv2.COLOR_GRAY2RGB)
1440
+ for lx1,ly1,lx2,ly2 in state["user_lines"]: cv2.line(vis,(lx1,ly1),(lx2,ly2),(255,80,80),3)
1441
+ if state["draw_start"]: cv2.circle(vis,state["draw_start"],6,(0,200,255),-1)
1442
+ return vis,state,msg
 
 
 
 
 
 
 
 
 
 
1443
 
1444
 
1445
  def cb_undo_door_line(state):
1446
+ if not state["user_lines"]: return None,state,"No lines to undo."
1447
+ state["user_lines"].pop(); state["draw_start"]=None
1448
+ walls_base=state.get("walls_base")
1449
+ if walls_base is None: return None,state,"Re-run preprocessing."
1450
+ thick=state.get("walls_thickness",8)
1451
+ walls_upd=apply_user_lines_to_walls(walls_base,state["user_lines"],thick)
1452
+ state["walls"]=walls_upd
1453
+ vis=cv2.cvtColor(walls_upd,cv2.COLOR_GRAY2RGB)
1454
+ for lx1,ly1,lx2,ly2 in state["user_lines"]: cv2.line(vis,(lx1,ly1),(lx2,ly2),(255,80,80),3)
1455
+ return vis,state,f"↩ Removed. Remaining:{len(state['user_lines'])}"
 
 
 
 
 
 
 
1456
 
1457
 
1458
  def cb_run_sam(state):
1459
+ walls=state.get("walls"); img=state.get("img_cropped"); img_clean=state.get("img_clean")
1460
+ if walls is None or img is None: return None,None,state,"Run preprocessing first."
1461
+ img_rgb=cv2.cvtColor(img,cv2.COLOR_BGR2RGB)
1462
+ ckpt=download_sam_if_needed()
1463
+ sam_enabled=ckpt is not None and Path(ckpt).exists()
 
 
 
 
 
 
 
 
 
1464
  if sam_enabled:
1465
+ rooms_mask,sam_room_masks=segment_with_sam(img_rgb,walls.copy(),ckpt)
1466
  else:
1467
+ rooms_mask=segment_rooms_flood(walls.copy()); sam_room_masks=[]
1468
+ state["_sam_room_masks"]=sam_room_masks
1469
+ if not np.count_nonzero(rooms_mask):
1470
+ return None,None,state,"⚠ rooms_mask empty."
1471
+ valid_mask,valid_rooms=filter_room_regions(rooms_mask,img.shape)
1472
+ if not valid_rooms: return None,None,state,"⚠ No valid rooms."
1473
+ src=img_clean if img_clean is not None else img
1474
+ rooms=measure_and_label_rooms(src,valid_rooms,sam_room_masks)
1475
+ if not rooms: return None,None,state,"⚠ No rooms after OCR."
1476
+ state["rooms"]=rooms; state["selected_ids"]=[]
1477
+ annotated=build_annotated_image(img,rooms); state["annotated"]=annotated
1478
+ table=[[r["id"],r["label"],f"{r['area_m2']} mΒ²",f"{r['score']:.2f}"] for r in rooms]
1479
+ return cv2.cvtColor(annotated,cv2.COLOR_BGR2RGB),table,state,f"βœ… {len(rooms)} rooms detected."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1480
 
1481
 
1482
  def cb_click_room(evt: gr.SelectData, state):
1483
+ annotated=state.get("annotated"); rooms=state.get("rooms",[]); img=state.get("img_cropped")
1484
+ if annotated is None or not rooms: return None,state,"Run SAM first."
1485
+ x,y=int(evt.index[0]),int(evt.index[1]); clicked_id=None
 
 
 
 
 
1486
  for room in rooms:
1487
+ cnt=room.get("contour")
1488
  if cnt is None: continue
1489
+ if cv2.pointPolygonTest(cnt,(float(x),float(y)),False)>=0:
1490
+ clicked_id=room["id"]; break
 
 
1491
  if clicked_id is None:
1492
+ state["selected_ids"]=[]; msg="Clicked outside β€” selection cleared."
 
1493
  else:
1494
+ sel=state["selected_ids"]
1495
+ if clicked_id in sel: sel.remove(clicked_id); msg=f"Room {clicked_id} deselected."
1496
+ else: sel.append(clicked_id); msg=f"Room {clicked_id} selected."
1497
+ state["selected_ids"]=sel
1498
+ new_ann=build_annotated_image(img,rooms,state["selected_ids"]); state["annotated"]=new_ann
1499
+ return cv2.cvtColor(new_ann,cv2.COLOR_BGR2RGB),state,msg
 
 
 
 
 
 
1500
 
1501
 
1502
  def cb_remove_selected(state):
1503
+ sel=state.get("selected_ids",[]); rooms=state.get("rooms",[]); img=state.get("img_cropped")
1504
+ if not sel: return None,None,state,"No rooms selected."
1505
+ removed=[r["label"] for r in rooms if r["id"] in sel]
1506
+ rooms=[r for r in rooms if r["id"] not in sel]
1507
+ for i,r in enumerate(rooms,1): r["id"]=i
1508
+ state["rooms"]=rooms; state["selected_ids"]=[]
1509
+ ann=build_annotated_image(img,rooms); state["annotated"]=ann
1510
+ table=[[r["id"],r["label"],f"{r['area_m2']} mΒ²",f"{r['score']:.2f}"] for r in rooms]
1511
+ return cv2.cvtColor(ann,cv2.COLOR_BGR2RGB),table,state,f"πŸ—‘ Removed:{', '.join(removed)}"
 
 
 
 
 
 
 
 
 
 
 
1512
 
1513
 
1514
  def cb_rename_selected(new_label, state):
1515
+ sel=state.get("selected_ids",[]); rooms=state.get("rooms",[]); img=state.get("img_cropped")
1516
+ if not sel: return None,None,state,"Select a room first."
1517
+ if not new_label.strip(): return None,None,state,"Enter a non-empty label."
 
 
 
 
 
1518
  for r in rooms:
1519
+ if r["id"] in sel: r["label"]=new_label.strip().upper()
1520
+ state["rooms"]=rooms
1521
+ ann=build_annotated_image(img,rooms,sel); state["annotated"]=ann
1522
+ table=[[r["id"],r["label"],f"{r['area_m2']} mΒ²",f"{r['score']:.2f}"] for r in rooms]
1523
+ return cv2.cvtColor(ann,cv2.COLOR_BGR2RGB),table,state,f"✏ Renamed to '{new_label.strip().upper()}'"
 
 
 
 
 
1524
 
1525
 
1526
  def cb_export_excel(state):
1527
+ rooms=state.get("rooms",[])
1528
+ if not rooms: return None,"No rooms to export."
1529
+ path=export_to_excel(rooms)
1530
+ return path,f"βœ… Exported {len(rooms)} rooms β†’ {Path(path).name}"
 
1531
 
1532
 
1533
  # ════════════════════════════════════════════════════════════════════════════
 
1535
  # ════════════════════════════════════════════════════════════════════════════
1536
 
1537
  CSS = """
1538
+ #title{text-align:center;font-size:1.8em;font-weight:700;color:#1F4E79}
1539
+ #subtitle{text-align:center;color:#555;margin-top:-8px;margin-bottom:16px}
1540
+ .step-card{border-left:4px solid #1F4E79!important;padding-left:10px!important}
1541
  """
1542
 
 
1543
  def _walls_to_rgb(s):
1544
+ w=s.get("walls")
1545
+ return None if w is None else cv2.cvtColor(w,cv2.COLOR_GRAY2RGB)
 
 
 
1546
 
1547
 
1548
+ with gr.Blocks(title="FloorPlan Analyser (GPU)") as app:
1549
+ state=gr.State(init_state())
1550
+ gr.Markdown("# 🏒 Floor Plan Room Analyser β€” NVIDIA GPU Build", elem_id="title")
 
1551
  gr.Markdown(
1552
+ f"EasyOCR gpu={'βœ…' if _TORCH_CUDA else '❌'} | "
1553
+ f"SAM FP16 autocast={'βœ…' if _TORCH_CUDA else '❌'} | "
1554
+ f"CuPy={'βœ…' if _CUPY else '❌'} | "
1555
+ f"cv2.cuda={'βœ…' if _CV2_CUDA else '❌'}",
1556
  elem_id="subtitle",
1557
  )
1558
+ status_box=gr.Textbox(label="Status",interactive=False,value="Idle.")
1559
 
 
 
 
 
 
 
 
1560
  with gr.Row():
1561
+ with gr.Column(scale=1,elem_classes="step-card"):
1562
  gr.Markdown("### 1️⃣ Upload Floor Plan")
1563
+ upload_btn=gr.UploadButton("πŸ“‚ Upload Image",file_types=["image"],size="sm")
1564
+ raw_preview=gr.Image(label="Loaded Image",height=320)
1565
+ with gr.Column(scale=1,elem_classes="step-card"):
1566
+ gr.Markdown("### 2️⃣ Pre-process")
1567
+ preprocess_btn=gr.Button("βš™ Run Preprocessing",variant="primary")
 
1568
  with gr.Tabs():
1569
+ with gr.Tab("Clean Image"): clean_img=gr.Image(label="After color removal",height=300)
1570
+ with gr.Tab("Walls"): walls_img=gr.Image(label="Extracted walls",height=300)
 
 
1571
 
 
1572
  with gr.Row():
1573
  with gr.Column(elem_classes="step-card"):
1574
+ gr.Markdown("### 3️⃣ Draw Door-Closing Lines")
1575
+ undo_line_btn=gr.Button("↩ Undo Last Line",size="sm")
1576
+ wall_draw_img=gr.Image(label="Wall mask",height=380,interactive=False)
1577
+
 
 
 
 
 
 
 
 
 
 
1578
  with gr.Row():
1579
+ with gr.Column(scale=2,elem_classes="step-card"):
1580
  gr.Markdown("### 4️⃣ SAM Segmentation + OCR")
1581
+ sam_btn=gr.Button("πŸ€– Run SAM + OCR",variant="primary")
1582
+ ann_img=gr.Image(label="Annotated rooms",height=480,interactive=False)
1583
+ with gr.Column(scale=1,elem_classes="step-card"):
 
 
 
 
 
1584
  gr.Markdown("### 5️⃣ Room Table & Actions")
1585
+ room_table=gr.Dataframe(headers=["ID","Label","Area","SAM Score"],
1586
+ datatype=["number","str","str","str"],
1587
+ interactive=False,label="Detected Rooms")
 
 
 
1588
  with gr.Group():
1589
+ rename_txt=gr.Textbox(placeholder="New label…",label="Rename Label")
 
1590
  with gr.Row():
1591
+ rename_btn=gr.Button("✏ Rename",size="sm")
1592
+ remove_btn=gr.Button("πŸ—‘ Remove Selected",size="sm",variant="stop")
 
1593
  gr.Markdown("---")
1594
+ export_btn=gr.Button("πŸ“Š Export to Excel",variant="secondary")
1595
+ excel_file=gr.File(label="Download Excel",visible=True)
1596
+
1597
+ upload_btn.upload(cb_load_image,[upload_btn,state],[raw_preview,state,status_box])
1598
+ preprocess_btn.click(cb_preprocess,[state],[clean_img,walls_img,state,status_box])\
1599
+ .then(_walls_to_rgb,[state],[wall_draw_img])
1600
+ wall_draw_img.select(cb_add_door_line,[state],[wall_draw_img,state,status_box])
1601
+ undo_line_btn.click(cb_undo_door_line,[state],[wall_draw_img,state,status_box])
1602
+ sam_btn.click(cb_run_sam,[state],[ann_img,room_table,state,status_box])
1603
+ ann_img.select(cb_click_room,[state],[ann_img,state,status_box])
1604
+ remove_btn.click(cb_remove_selected,[state],[ann_img,room_table,state,status_box])
1605
+ rename_btn.click(cb_rename_selected,[rename_txt,state],[ann_img,room_table,state,status_box])
1606
+ export_btn.click(cb_export_excel,[state],[excel_file,status_box])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1607
 
1608
 
1609
  if __name__ == "__main__":