Spaces:
Paused
Paused
Update app.py
Browse files
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 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
|
|
|
|
|
|
|
|
|
| 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 <
|
| 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 |
-
|
|
|
|
| 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(-
|
| 905 |
-
for dx in range(-
|
| 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 |
-
|
| 936 |
-
|
| 937 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 944 |
sam.to(device); sam.eval()
|
| 945 |
predictor = SamPredictor(sam)
|
|
|
|
| 946 |
except Exception as e:
|
| 947 |
-
print(f"[SAM] Load failed
|
| 948 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 949 |
|
| 950 |
-
|
| 951 |
-
|
| 952 |
-
|
| 953 |
|
| 954 |
predictor.set_image(img_rgb)
|
| 955 |
-
|
| 956 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 972 |
-
|
| 973 |
-
|
| 974 |
-
|
| 975 |
-
|
|
|
|
|
|
|
| 976 |
|
| 977 |
-
|
| 978 |
-
|
| 979 |
-
|
| 980 |
-
|
| 981 |
|
| 982 |
-
|
|
|
|
|
|
|
|
|
|
| 983 |
|
| 984 |
-
|
| 985 |
-
|
| 986 |
-
return results
|
| 987 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 988 |
|
| 989 |
-
|
| 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 |
-
|
| 1005 |
-
|
| 1006 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 1013 |
-
|
| 1014 |
-
|
| 1015 |
-
|
| 1016 |
-
|
| 1017 |
-
|
| 1018 |
-
|
| 1019 |
-
|
| 1020 |
-
|
| 1021 |
-
|
| 1022 |
-
|
| 1023 |
-
|
| 1024 |
-
|
| 1025 |
-
|
| 1026 |
-
|
| 1027 |
-
|
| 1028 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1029 |
hull = cv2.convexHull(cnt)
|
| 1030 |
ha = cv2.contourArea(hull)
|
| 1031 |
-
if ha > 0 and (
|
| 1032 |
-
|
| 1033 |
-
|
| 1034 |
-
entry["contour"] = cnt
|
| 1035 |
-
entry["area_px"] = area
|
| 1036 |
-
valid.append(entry)
|
| 1037 |
|
| 1038 |
-
|
|
|
|
|
|
|
|
|
|
| 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]
|
| 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 |
-
|
| 1371 |
|
| 1372 |
-
|
| 1373 |
-
|
| 1374 |
|
| 1375 |
-
|
| 1376 |
-
|
| 1377 |
-
|
| 1378 |
-
|
| 1379 |
-
|
| 1380 |
-
|
| 1381 |
-
|
| 1382 |
-
|
| 1383 |
-
|
| 1384 |
-
|
| 1385 |
-
|
| 1386 |
-
|
| 1387 |
-
|
| 1388 |
-
|
| 1389 |
-
|
| 1390 |
-
|
| 1391 |
-
|
| 1392 |
-
|
| 1393 |
-
|
| 1394 |
-
|
| 1395 |
-
|
| 1396 |
-
|
| 1397 |
-
|
| 1398 |
-
|
| 1399 |
-
|
| 1400 |
-
"confidence": 0.95,
|
| 1401 |
-
})
|
| 1402 |
|
| 1403 |
-
state["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 |
|