Pream912 commited on
Commit
5c5fef7
Β·
verified Β·
1 Parent(s): 0db80c6

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +287 -113
app.py CHANGED
@@ -821,11 +821,14 @@ def apply_user_lines_to_walls(
821
 
822
 
823
  def segment_rooms_flood(walls: np.ndarray) -> np.ndarray:
824
- h, w = walls.shape
825
- walls[:5, :] = 255; walls[-5:, :] = 255
826
- walls[:, :5] = 255; walls[:, -5:] = 255
827
-
828
- filled = walls.copy()
 
 
 
829
  mask = np.zeros((h+2, w+2), np.uint8)
830
  for sx, sy in [(0,0),(w-1,0),(0,h-1),(w-1,h-1),
831
  (w//2,0),(w//2,h-1),(0,h//2),(w-1,h//2)]:
@@ -833,6 +836,7 @@ def segment_rooms_flood(walls: np.ndarray) -> np.ndarray:
833
  cv2.floodFill(filled, mask, (sx, sy), 255)
834
 
835
  rooms = cv2.bitwise_not(filled)
 
836
  rooms = cv2.bitwise_and(rooms, cv2.bitwise_not(walls))
837
  rooms = cv2.morphologyEx(rooms, cv2.MORPH_OPEN, np.ones((2,2), np.uint8))
838
  return rooms
@@ -888,21 +892,25 @@ def generate_prompts(
888
  inv = cv2.bitwise_not(walls_mask)
889
  n, labels, stats, centroids = cv2.connectedComponentsWithStats(inv, connectivity=8)
890
 
 
 
 
891
  pts, lbls = [], []
892
  for i in range(1, n):
893
  area = int(stats[i, cv2.CC_STAT_AREA])
894
- if area < SAM_CLOSET_THR:
895
  continue
896
  bx = int(stats[i, cv2.CC_STAT_LEFT]); by = int(stats[i, cv2.CC_STAT_TOP])
897
  bw = int(stats[i, cv2.CC_STAT_WIDTH]); bh = int(stats[i, cv2.CC_STAT_HEIGHT])
898
- if bx <= 5 and by <= 5 and bx+bw >= w-5 and by+bh >= h-5:
 
899
  continue
900
  cx = int(np.clip(centroids[i][0], 0, w-1))
901
  cy = int(np.clip(centroids[i][1], 0, h-1))
902
  if walls_mask[cy, cx] > 0:
903
  found = False
904
- for dy in range(-10, 11, 2):
905
- for dx in range(-10, 11, 2):
906
  ny2, nx2 = cy+dy, cx+dx
907
  if 0<=ny2<h and 0<=nx2<w and walls_mask[ny2,nx2]==0:
908
  cx, cy = nx2, ny2; found = True; break
@@ -913,6 +921,8 @@ def generate_prompts(
913
  for pt in _find_thick_wall_neg_prompts(walls_mask):
914
  pts.append(list(pt)); lbls.append(0)
915
 
 
 
916
  return np.array(pts, dtype=np.float32), np.array(lbls, dtype=np.int32)
917
 
918
 
@@ -932,35 +942,57 @@ def segment_with_sam(
932
  img_rgb: np.ndarray,
933
  walls: np.ndarray,
934
  sam_ckpt: str,
935
- ) -> List[Dict]:
936
- """Returns list of room dicts with keys: mask, score, prompt."""
937
- rooms_flood = segment_rooms_flood(walls.copy())
 
 
 
 
 
 
 
 
938
 
939
  try:
940
  import torch
941
  from segment_anything import sam_model_registry, SamPredictor
 
 
 
 
 
942
  device = "cuda" if torch.cuda.is_available() else "cpu"
943
- sam = sam_model_registry["vit_h"](checkpoint=sam_ckpt)
 
944
  sam.to(device); sam.eval()
945
  predictor = SamPredictor(sam)
 
946
  except Exception as e:
947
- print(f"[SAM] Load failed: {e} β€” fallback to flood-fill")
948
- return _flood_fill_rooms(rooms_flood)
 
 
 
 
 
949
 
950
- pts, lbls = generate_prompts(walls, rooms_flood)
951
- if len(pts) == 0:
952
- return _flood_fill_rooms(rooms_flood)
953
 
954
  predictor.set_image(img_rgb)
955
- pos_pts = [(tuple(p), int(l)) for p, l in zip(pts, lbls) if l == 1]
956
- neg_pts = [tuple(p) for p, l in zip(pts, lbls) if l == 0]
 
 
957
 
958
  neg_coords = np.array(neg_pts, dtype=np.float32) if neg_pts else None
959
  neg_lbls = np.zeros(len(neg_pts), dtype=np.int32) if neg_pts else None
960
- denoise_k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5,5))
961
 
962
- results = []
963
  for (px, py), lbl in pos_pts:
 
964
  if neg_coords is not None:
965
  pt_c = np.vstack([[[px, py]], neg_coords])
966
  pt_l = np.concatenate([[lbl], neg_lbls])
@@ -968,80 +1000,229 @@ def segment_with_sam(
968
  pt_c = np.array([[px, py]], dtype=np.float32)
969
  pt_l = np.array([lbl], dtype=np.int32)
970
 
971
- masks, scores, _ = predictor.predict(
972
- point_coords=pt_c, point_labels=pt_l, multimask_output=True
973
- )
974
- best = int(np.argmax(scores))
975
- if float(scores[best]) < SAM_MIN_SCORE: continue
 
 
976
 
977
- m = (masks[best] > 0).astype(np.uint8) * 255
978
- m = cv2.bitwise_and(m, rooms_flood)
979
- m = cv2.morphologyEx(m, cv2.MORPH_OPEN, denoise_k)
980
- if not np.any(m): continue
981
 
982
- results.append({"mask": m, "score": float(scores[best]), "prompt": (px, py)})
 
 
 
983
 
984
- if not results:
985
- return _flood_fill_rooms(rooms_flood)
986
- return results
987
 
 
 
 
 
 
 
 
988
 
989
- def _flood_fill_rooms(rooms_flood: np.ndarray) -> List[Dict]:
990
- contours, _ = cv2.findContours(
991
- rooms_flood, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
992
- )
993
- results = []
994
- for cnt in contours:
995
- m = np.zeros_like(rooms_flood)
996
- cv2.drawContours(m, [cnt], -1, 255, -1)
997
- M = cv2.moments(cnt)
998
- cx = int(M["m10"]/M["m00"]) if M["m00"] else 0
999
- cy = int(M["m01"]/M["m00"]) if M["m00"] else 0
1000
- results.append({"mask": m, "score": 1.0, "prompt": (cx, cy)})
1001
- return results
1002
 
 
 
 
1003
 
1004
- def filter_room_masks(
1005
- room_masks: List[Dict], img_shape: Tuple
1006
- ) -> List[Dict]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1007
  h, w = img_shape[:2]
1008
  img_area = float(h * w)
 
1009
  min_area = img_area * MIN_ROOM_AREA_FRAC
1010
  max_area = img_area * MAX_ROOM_AREA_FRAC
1011
  min_dim = w * MIN_ROOM_DIM_FRAC
1012
- margin = max(5.0, w * BORDER_MARGIN_FRAC)
1013
-
1014
- valid = []
1015
- for entry in room_masks:
1016
- m = entry["mask"]
1017
- cnts, _ = cv2.findContours(m, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
1018
- if not cnts: continue
1019
- cnt = max(cnts, key=cv2.contourArea)
1020
- area = cv2.contourArea(cnt)
1021
- if not (min_area <= area <= max_area): continue
1022
- bx, by, bw, bh = cv2.boundingRect(cnt)
1023
- if bx < margin or by < margin or bx+bw > w-margin or by+bh > h-margin:
1024
- continue
1025
- if bw < min_dim and bh < min_dim: continue
1026
- asp = max(bw, bh) / (min(bw, bh) + 1e-6)
1027
- if asp > MAX_ASPECT_RATIO: continue
1028
- if (area / (bw*bh+1e-6)) < MIN_EXTENT: continue
 
 
 
 
 
 
 
 
 
 
 
1029
  hull = cv2.convexHull(cnt)
1030
  ha = cv2.contourArea(hull)
1031
- if ha > 0 and (area / ha) < MIN_SOLIDITY: continue
1032
-
1033
- entry = dict(entry)
1034
- entry["contour"] = cnt
1035
- entry["area_px"] = area
1036
- valid.append(entry)
1037
 
1038
- return valid
 
 
 
1039
 
1040
 
1041
  def pixel_area_to_m2(area_px: float) -> float:
1042
  return area_px * (2.54 / DPI) ** 2 * (SCALE_FACTOR ** 2) / 10000
1043
 
1044
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1045
  def run_ocr_on_room(img_bgr: np.ndarray, contour: np.ndarray) -> Optional[str]:
1046
  try:
1047
  import easyocr
@@ -1357,59 +1538,52 @@ def cb_undo_door_line(state):
1357
  def cb_run_sam(state):
1358
  walls = state.get("walls")
1359
  img = state.get("img_cropped")
 
1360
  if walls is None or img is None:
1361
  return None, None, state, "Run preprocessing first."
1362
 
1363
- print("[SAM] Downloading checkpoint if needed…")
1364
- ckpt = download_sam_if_needed()
1365
- if ckpt is None:
1366
- return None, None, state, "❌ SAM checkpoint download failed."
1367
 
1368
- print("[SAM] Segmenting rooms…")
1369
  img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
1370
- raw = segment_with_sam(img_rgb, walls.copy(), ckpt)
1371
 
1372
- print("[SAM] Filtering rooms…")
1373
- filtered = filter_room_masks(raw, img.shape)
1374
 
1375
- print("[SAM] Running OCR…")
1376
- rooms = []
1377
- for idx, entry in enumerate(filtered, 1):
1378
- cnt = entry["contour"]
1379
- label = run_ocr_on_room(img, cnt)
1380
- if not label or not validate_label(label):
1381
- label = f"ROOM {idx}"
1382
-
1383
- M = cv2.moments(cnt)
1384
- cx = int(M["m10"]/M["m00"]) if M["m00"] else 0
1385
- cy = int(M["m01"]/M["m00"]) if M["m00"] else 0
1386
- area_px = entry["area_px"]
1387
- area_m2 = pixel_area_to_m2(area_px)
1388
- bx,by,bw,bh = cv2.boundingRect(cnt)
1389
-
1390
- rooms.append({
1391
- "id": idx,
1392
- "label": label,
1393
- "contour": cnt,
1394
- "mask": entry["mask"],
1395
- "score": entry["score"],
1396
- "area_px": round(area_px, 1),
1397
- "area_m2": round(area_m2, 2),
1398
- "bbox": [bx, by, bw, bh],
1399
- "centroid": [cx, cy],
1400
- "confidence": 0.95,
1401
- })
1402
 
1403
- state["rooms"] = rooms
1404
  state["selected_ids"] = []
1405
 
1406
- print("[SAM] Rendering…")
1407
  annotated = build_annotated_image(img, rooms)
1408
  state["annotated"] = annotated
1409
 
1410
  table = [[r["id"], r["label"], f"{r['area_m2']} mΒ²", f"{r['score']:.2f}"]
1411
  for r in rooms]
1412
-
1413
  ann_rgb = cv2.cvtColor(annotated, cv2.COLOR_BGR2RGB)
1414
  return ann_rgb, table, state, f"βœ… {len(rooms)} rooms detected."
1415
 
 
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)]:
 
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
 
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:
911
  found = False
912
+ for dy in range(-15, 16, 2):
913
+ for dx in range(-15, 16, 2):
914
  ny2, nx2 = cy+dy, cx+dx
915
  if 0<=ny2<h and 0<=nx2<w and walls_mask[ny2,nx2]==0:
916
  cx, cy = nx2, ny2; found = True; break
 
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
 
 
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
959
  from segment_anything import sam_model_registry, SamPredictor
960
+
961
+ if not Path(sam_ckpt).exists():
962
+ print(" [SAM] Model not found β€” using flood-fill")
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)
970
+
971
  except Exception as e:
972
+ print(f" [SAM] Load failed ({e}) β€” using flood-fill")
973
+ return rooms_flood, []
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)
988
+ accepted = 0
989
 
990
  neg_coords = np.array(neg_pts, dtype=np.float32) if neg_pts else None
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])
 
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)
1076
+ bx, by, bw_arr, bh_arr = bboxes[:,0], bboxes[:,1], bboxes[:,2], bboxes[:,3]
1077
+
1078
+ area_ok = (areas >= min_area) & (areas <= max_area)
1079
+ border_ok = (bx >= margin) & (by >= margin) & \
1080
+ (bx + bw_arr <= w - margin) & (by + bh_arr <= h - margin)
1081
+ dim_ok = (bw_arr >= min_dim) | (bh_arr >= min_dim)
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)
1089
+ valid_rooms = []
1090
+ for i in cheap_pass:
1091
+ cnt = contours[i]
1092
  hull = cv2.convexHull(cnt)
1093
  ha = cv2.contourArea(hull)
1094
+ if ha > 0 and (areas[i] / ha) >= MIN_SOLIDITY:
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
 
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