From af08410eb95e23e570161da58cb72566489ed74e Mon Sep 17 00:00:00 2001 From: Faiz Mansoor <1faizmansoor123@gmail.com> Date: Sat, 17 May 2025 16:31:35 +0530 Subject: [PATCH] blue print to json --- .gitignore | 4 +- 3d-backend/__pycache__/app.cpython-311.pyc | Bin 728 -> 0 bytes 3d-backend/__pycache__/routes.cpython-311.pyc | Bin 1329 -> 0 bytes 3d-backend/routes.py | 59 ++- 3d-backend/utils.py | 0 3d-backend/utils/opencv_utils.py | 463 ++++++++++++++++++ backend/__pycache__/app.cpython-311.pyc | Bin 1547 -> 0 bytes backend/__pycache__/app.cpython-312.pyc | Bin 1305 -> 0 bytes backend/__pycache__/routes.cpython-311.pyc | Bin 16162 -> 0 bytes backend/__pycache__/routes.cpython-312.pyc | Bin 14224 -> 0 bytes 10 files changed, 518 insertions(+), 8 deletions(-) delete mode 100644 3d-backend/__pycache__/app.cpython-311.pyc delete mode 100644 3d-backend/__pycache__/routes.cpython-311.pyc delete mode 100644 3d-backend/utils.py create mode 100644 3d-backend/utils/opencv_utils.py delete mode 100644 backend/__pycache__/app.cpython-311.pyc delete mode 100644 backend/__pycache__/app.cpython-312.pyc delete mode 100644 backend/__pycache__/routes.cpython-311.pyc delete mode 100644 backend/__pycache__/routes.cpython-312.pyc diff --git a/.gitignore b/.gitignore index 2333d60..40c6ba1 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ -/uploads \ No newline at end of file +/uploads +__pycache__/ +*.pyc diff --git a/3d-backend/__pycache__/app.cpython-311.pyc b/3d-backend/__pycache__/app.cpython-311.pyc deleted file mode 100644 index c997cd4776a3b85c0cce466c6d2d5a6b6142a620..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 728 zcmZ8d&ubG=5T3U`6Watyl^mMGf)@j(0X-QT!CDI*QVY^cSdiDe*SK`EyS&{5%_)_D z{s&$>c#25u!VN&VYTbRkO#uGLU@6+Q zGyjCnM_|B+3q8^%1en59SLvy3jer82Ot9i&*#~W%>z|EPxaM$;Y2WmTg4L**5Ws44 z8Li*UnBbo-P%~lX82+1-W4V`5&0orbDoM-(8b(i!o?pryj1A%UBQA!;It`rqiQDIa zaJ)#q1cIMBZx*E9$-px!f2E?%B`vr!VTwa=f_j*kJsyU%%ZJt;?VNFsS@*4)wYS8y zjsw@H%);qkLCE$wDlA%08}@O?MQFFE7y9-KkM9d-!0mk=o<)9OA5dplOW%B9(*gG) z``LhYj$G>54_NIa8)nlW*a_YydBG`rC44^;1%%%~4U1?-Fx|WzmmkI(jaX|4nLn)N z6Y~@a!QF*i>Y9#F=rXvuq66V1ONojzlA_~v+&<&0WS3{7(w$OZv5EbQ+-}cjeV0EL zw=k51ha)sIl@KyBfK+1GUO&HKC;sL1j4;BaJq+Qh~pYFuo z$&QT!q7BrAK9xf9;DVmqnE0Xey@mV-66nDk6bz-%zA1GJCQq5Y%lRWDvp2IdznPug znce$2n@t1K=*>$@*Cl{Ig^`f*Tsin1mHR+}5(gTjOd3*IYRF}o2uyO6vJ%aya!LY5 zwlVrqk^{;rmCG99sk)wkOry$fY8&@^gd7tArgn_PIyqByn_whzOqffe+G(stsLte? zM2&l?7xqt6=?f^N{}GD|a%fDKW^Z||fbmedVLL2L-)=gdMTI`MuD2q?pZc;uLY>{Q z{b12sUe~5#d@@vO7*{RO^>&ZE9>NT*jt_lI}*BI;(|hIheNR;4@g0<01O z3s@I{ljoxnmA6D^mAOz_t*noU2Ik{7F`D;V)hhG-TFY_P3R0+xwz*b=g<1n)>nm0; zLdo+(rD+9Ap;Gs37jL3;bAEbeac1@w7q2o@ye4x))n94_wi9NmUUN<4@hWvL>MO|6 z6dD3tj1sApR;|XkFQYr3&p$1FeBHe5BVc}RxxQ!4yKIKrE6ki>{&L_o%^Q}zUW|Mm zH?0-s2IeO#R<-0thez z+xnnW>*>Y5UhL|{fsDQ=AQDeR=zl^SV=vPV4-a1k4|F&`zLo7>nd@fe#iz{sXYsVZ z<)$ynk1s0Im$b*13Ccny)M}O=SWTOY9&+&>c}fTo&qbY~8g+y7*My-rtYt=RF0Rt^ z<8JK00lI*{j^e5QVZ);>hkeAy(G-7!|0@bCkdT22yLZ=Bma z|6T6;TwC4Gj<(Z#kZDWzGMk@vM!GQ3gNZ&&pt4W2Z*yPgHWzwiv` 0 else 0 + area = cv2.contourArea(cnt) + + # Text typically has small area and specific aspect ratio ranges + # Fine-tune these parameters based on your blueprints + is_text = (area < 500 and + (0.5 < aspect_ratio < 15 or # For normal text + aspect_ratio < 0.5)) # For vertical text like 'CLOSET' + + if is_text: + # Paint over text with white (or background color) + cv2.rectangle(result_img, (x, y), (x + w, y + h), (255, 255, 255), -1) + + # Step 7: Additional pass for dimensions text which often has specific patterns + # Look for text near walls that resembles dimensions (e.g., "12' x 10'") + lower_text = gray.copy() + + # Apply a more aggressive threshold to catch dimension text + _, dim_thresh = cv2.threshold(gray, 180, 255, cv2.THRESH_BINARY_INV) + + # Dilate to connect nearby characters in dimension text + dim_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) + dim_text = cv2.dilate(dim_thresh, dim_kernel, iterations=1) + + # Find contours for dimension text + dim_contours, _ = cv2.findContours(dim_text, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + for cnt in dim_contours: + x, y, w, h = cv2.boundingRect(cnt) + aspect_ratio = w / float(h) if h > 0 else 0 + area = cv2.contourArea(cnt) + + # Dimensions text typically has specific characteristics + is_dim_text = (area < 1000 and area > 50 and + (1.5 < aspect_ratio < 10 or # For horizontal dimensions + aspect_ratio < 0.7)) # For vertical dimensions + + if is_dim_text: + # Check if this overlaps with any previously identified wall + region = walls_mask[y:y+h, x:x+w] + if np.sum(region) < 0.5 * w * h * 255: # Less than 50% overlap with walls + cv2.rectangle(result_img, (x, y), (x + w, y + h), (255, 255, 255), -1) + + # Save the result for debugging + cv2.imwrite(os.path.join(debug_dir, "text_removed_result.png"), result_img) + + return result_img + + +def process_blueprint_image(file_path): + """ + Process blueprint image to detect walls and extract their coordinates. + Specifically designed for floor plans with black lines representing walls. + """ + print(f"Processing file: {file_path}") + if not os.path.exists(file_path): + raise FileNotFoundError(f"File not found: {file_path}") + + # Load image + original = cv2.imread(file_path) + if original is None: + raise ValueError(f"Image at {file_path} could not be loaded. Make sure it's a PNG or JPEG.") + + # Create a copy for visualization + vis_image = original.copy() + + # Convert to grayscale + gray = cv2.cvtColor(original, cv2.COLOR_BGR2GRAY) + + # Threshold to isolate black lines (walls) + _, binary = cv2.threshold(gray, 50, 255, cv2.THRESH_BINARY_INV) + + # Create a debug image to save intermediate results + debug_binary = cv2.cvtColor(binary, cv2.COLOR_GRAY2BGR) + + # Find line segments using Hough Line Transform + # For precise wall detection in blueprints, we'll identify line segments and then group them + edges = cv2.Canny(binary, 50, 150, apertureSize=3) + + # Use probabilistic Hough transform to detect line segments + lines = cv2.HoughLinesP(edges, 1, np.pi/180, threshold=50, minLineLength=50, maxLineGap=10) + + # Create a new image to draw the detected lines for visualization + line_image = np.zeros_like(original) + + # Process detected lines and store wall segments + wall_segments = [] + if lines is not None: + for i, line in enumerate(lines): + x1, y1, x2, y2 = line[0] + # Draw the line on the visualization image + cv2.line(line_image, (x1, y1), (x2, y2), (0, 255, 0), 2) + + # Store wall segment data + wall_segments.append({ + "id": f"segment_{i}", + "start": [int(x1), int(y1)], + "end": [int(x2), int(y2)], + "length": np.sqrt((x2 - x1)**2 + (y2 - y1)**2) + }) + + # Create a mask of walls by dilating the line segments + wall_mask = np.zeros_like(gray) + if lines is not None: + for line in lines: + x1, y1, x2, y2 = line[0] + cv2.line(wall_mask, (x1, y1), (x2, y2), 255, 5) # Thicker line for better detection + + # Dilate the mask to connect nearby wall segments + kernel = np.ones((5, 5), np.uint8) + wall_mask = cv2.dilate(wall_mask, kernel, iterations=1) + + # Save the wall mask for debugging + debug_dir = os.path.dirname(file_path) + cv2.imwrite(os.path.join(debug_dir, "wall_mask.png"), wall_mask) + + # Find contours on the wall mask to identify room boundaries + contours, _ = cv2.findContours(wall_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + # Process room boundaries + rooms = [] + for i, cnt in enumerate(contours): + area = cv2.contourArea(cnt) + if area < 1000: # Skip very small contours + continue + + # Simplify the room polygon + epsilon = 0.01 * cv2.arcLength(cnt, True) + approx = cv2.approxPolyDP(cnt, epsilon, True) + + # Extract room boundary points + points = approx.reshape(-1, 2).tolist() + + # Add room data + rooms.append({ + "id": f"room_{i}", + "polygon": points, + "area": area + }) + + # Draw room boundary on visualization + cv2.drawContours(vis_image, [approx], 0, (0, 0, 255), 2) + + # Detect individual walls using a different approach + # We'll use horizontal and vertical kernel matching to detect wall sections + + # Morphological operations to isolate walls + kernel_h = np.ones((1, 15), np.uint8) # Horizontal kernel + kernel_v = np.ones((15, 1), np.uint8) # Vertical kernel + + # Extract horizontal and vertical components + horizontal = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel_h) + vertical = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel_v) + + # Combine horizontal and vertical components + wall_lines = cv2.bitwise_or(horizontal, vertical) + + # Dilate to connect components + wall_lines = cv2.dilate(wall_lines, np.ones((3, 3), np.uint8), iterations=1) + + # Save wall lines for debugging + cv2.imwrite(os.path.join(debug_dir, "wall_lines.png"), wall_lines) + + # Find contours of wall sections + wall_contours, _ = cv2.findContours(wall_lines, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + # Process wall sections + walls = [] + for i, cnt in enumerate(wall_contours): + area = cv2.contourArea(cnt) + if area < 100: # Skip very small contours + continue + + # Get bounding rectangle + x, y, w, h = cv2.boundingRect(cnt) + + # Determine if horizontal or vertical wall + is_horizontal = w > h + + # Simplify the wall polygon + epsilon = 0.01 * cv2.arcLength(cnt, True) + approx = cv2.approxPolyDP(cnt, epsilon, True) + + # Extract wall points + points = approx.reshape(-1, 2).tolist() + + # Add wall data + wall_type = "horizontal" if is_horizontal else "vertical" + walls.append({ + "id": f"wall_{i}", + "type": wall_type, + "polygon": points, + "area": area, + "bounds": { + "x": int(x), + "y": int(y), + "width": int(w), + "height": int(h) + } + }) + + # Draw wall on visualization + color = (0, 255, 0) if is_horizontal else (255, 0, 0) + cv2.drawContours(vis_image, [approx], 0, color, 2) + + # Add wall ID text + cv2.putText(vis_image, f"wall_{i}", (x + w//2 - 20, y + h//2), + cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 0, 0), 1) + + # Save the visualization image + debug_path = os.path.join(debug_dir, f"debug_{os.path.basename(file_path)}") + cv2.imwrite(debug_path, vis_image) + + # Save lines visualization + cv2.imwrite(os.path.join(debug_dir, "detected_lines.png"), line_image) + + # Combine horizontal and vertical wall detection for final result + combined_vis = cv2.addWeighted(original, 0.7, line_image, 0.3, 0) + cv2.imwrite(os.path.join(debug_dir, "combined_detection.png"), combined_vis) + + return { + "walls": walls, + "wall_segments": wall_segments, + "rooms": rooms, + "image_dimensions": { + "width": original.shape[1], + "height": original.shape[0] + } + } + +def reconstruct_blueprint(json_path, output_path="reconstructed_blueprint.png", canvas_size=None): + """ + Reconstruct blueprint from JSON data with improved visualization. + """ + # Load the JSON blueprint data + with open(json_path, 'r') as file: + blueprint = json.load(file) + + # Get image dimensions from JSON or use default + if "image_dimensions" in blueprint["data"]: + width = blueprint["data"]["image_dimensions"]["width"] + height = blueprint["data"]["image_dimensions"]["height"] + canvas_size = (width, height) + elif canvas_size is None: + canvas_size = (1600, 1200) + + # Create a blank white canvas + canvas = np.ones((canvas_size[1], canvas_size[0], 3), dtype=np.uint8) * 255 + + # Draw wall segments if available + if "wall_segments" in blueprint["data"]: + for segment in blueprint["data"]["wall_segments"]: + start = tuple(segment["start"]) + end = tuple(segment["end"]) + cv2.line(canvas, start, end, (0, 0, 0), 2) + + # Draw walls with different colors + if "walls" in blueprint["data"]: + for wall in blueprint["data"]["walls"]: + if len(wall["polygon"]) < 3: # Skip invalid polygons + continue + + # Convert points to numpy array + polygon = np.array(wall["polygon"], dtype=np.int32) + polygon = polygon.reshape((-1, 1, 2)) + + # Choose color based on wall type + if wall.get("type") == "horizontal": + color = (0, 0, 255) # Red for horizontal walls + else: + color = (255, 0, 0) # Blue for vertical walls + + # Draw filled polygon with transparency + filled = canvas.copy() + cv2.fillPoly(filled, [polygon], color=color) + cv2.addWeighted(filled, 0.3, canvas, 0.7, 0, canvas) + + # Draw outline + cv2.polylines( + canvas, + [polygon], + isClosed=True, + color=(0, 0, 0), + thickness=1 + ) + + # Add wall ID text + if "bounds" in wall: + x = wall["bounds"]["x"] + y = wall["bounds"]["y"] + w = wall["bounds"]["width"] + h = wall["bounds"]["height"] + cv2.putText( + canvas, + wall["id"], + (x + w//2 - 20, y + h//2), + cv2.FONT_HERSHEY_SIMPLEX, + 0.4, + (0, 0, 0), + 1 + ) + + # Draw room boundaries if available + if "rooms" in blueprint["data"]: + for room in blueprint["data"]["rooms"]: + if len(room["polygon"]) < 3: # Skip invalid polygons + continue + + # Convert points to numpy array + polygon = np.array(room["polygon"], dtype=np.int32) + polygon = polygon.reshape((-1, 1, 2)) + + # Draw room boundary + cv2.polylines( + canvas, + [polygon], + isClosed=True, + color=(0, 255, 0), + thickness=1 + ) + + # Save the reconstructed blueprint + cv2.imwrite(output_path, canvas) + print(f"Reconstructed blueprint saved to: {output_path}") + + return canvas + +def extract_individual_walls(file_path): + """ + Alternative approach to extract individual walls from the blueprint + by detecting black lines directly from the image. + """ + image = cv2.imread(file_path) + gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + + # Threshold to get black lines + _, binary = cv2.threshold(gray, 50, 255, cv2.THRESH_BINARY_INV) + + # Find contours + contours, _ = cv2.findContours(binary, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE) + + # Filter and process wall contours + walls = [] + for i, cnt in enumerate(contours): + area = cv2.contourArea(cnt) + if area < 50: # Skip very small contours + continue + + # Get bounding rect to determine if horizontal or vertical + x, y, w, h = cv2.boundingRect(cnt) + + # Determine if it's likely a wall + aspect_ratio = float(w) / h if h > 0 else 0 + is_wall = (aspect_ratio > 5 or aspect_ratio < 0.2) and min(w, h) < 20 + + if is_wall: + # Simplify the wall polygon + epsilon = 0.01 * cv2.arcLength(cnt, True) + approx = cv2.approxPolyDP(cnt, epsilon, True) + + # Extract wall points + points = approx.reshape(-1, 2).tolist() + + # Add wall data + wall_type = "horizontal" if w > h else "vertical" + walls.append({ + "id": f"wall_{i}", + "type": wall_type, + "polygon": points, + "area": area, + "bounds": { + "x": int(x), + "y": int(y), + "width": int(w), + "height": int(h) + } + }) + + return walls \ No newline at end of file diff --git a/backend/__pycache__/app.cpython-311.pyc b/backend/__pycache__/app.cpython-311.pyc deleted file mode 100644 index e146145b3944f33eac25fb4f926b1a304e9e482c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1547 zcmcgs&2Jk;6o0e3_QqbvZell(f<#ac!~qN{w+f+_I5F)ZNOf~yu~==qV`rQ7t~Ilc zl!(KED$!#ONX?;#o$_g7oZv-p!1Au_pg@H%C)eb|Zc%yM%Tw zoSHo&SUKy0HFY?>hgmu{4-}3@lRg|@GYE+UV0#%)5r~}3PojQJGJ8Za}BTKTKgCSWz1ud+M=ABP0BQM zv@bCQmq_M5+b1lGG8dlbPRff{Ypa|xUMo|_LB=_uV!GO_^v)~0vA{9KHBo@|3Akwn^DsoFOI}mdywz+J;Rd8zzP2QH(N}6$BeY z@SCWb6^K`1ee;L>cmyQ)i-D8|3gn4bv z7_>Fpt6@32G*w_rCbfyXyt=APpoDC_!~+b@!`Ba`^TKVFB@_E|hvuLILsQ z{SX$;QXg_bZb$Ayp$CN!3W$*sf0C)EWNPPfm(29Y%s?WfLIxoCa~Y&JUg=&Bz8QmW wj)75^1SNBC`>8w|%Cmh%{h{)GWoPzb;nD4&(p9eXlq=zg1~Mp@5EbY4AB-GjK>z>% diff --git a/backend/__pycache__/app.cpython-312.pyc b/backend/__pycache__/app.cpython-312.pyc deleted file mode 100644 index 31d1f0bb6f133482377de422a4cb0d3ead54ed55..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1305 zcmcIkO>7%Q6n?X_>y5n|r-_q7XeCl{XxyK)Dm|eJl_rg84?)7sfyL@-;~6^}?~gUJ zj+CeeBarAZ2c!lG3671D8wgc`L+@N1LRFXwfdrS_5~aQL)S0!N{&dp1}bBD5JkJJ>}pA0(bxf2nxXI?#MyX)&ecW4i0rkN6L=_Bg{A% z1R7AZl^i5eot@I?O*}m=MWTp(w5rbri=_4O)4Qy(CtgM&~tk8Ds z^qfs|{c{LtZs}APcU+P~1hKw=&0fgK{}0;k=Ocs1&_gs!@{LIVHYU%VIiA0(lOb|~ z3~!BW$`@4&!3tVI#lo&K=8BpyW&@8mgt^4y$giywklKe#Hz14s^I1SP#& z0o;Q_e%d$Mp^j@>8H)PcYIZJ8CTejjA{+9~l(CozGo+k*b;>N6ctc~l+yv$c+dxb; zoU2L`FRxC&SDELO@rp+l{iHHOc_WFNm4+93L4_`R)s~mg%7RyI(1=vLW^<~!BC=7O zxM@K)D$|Uqe5#9W_}3a)|MZ9Qqp|Ou?ZWjP>x0MdcC6WsK6@a;u>1VA@VCp9EmJm; zGG~Lb5Id=a8eujfMVBlqNLAR9tpvMW@SD;#t`JApzxy+Obn~a&c42zQnt5F9ShqU* zEv7h%0oM&ZKXTnw^YT>s`PbhDxKqSJUyOMo^k&QwVGz2|suzca7IR?+ab1o_T^Kbr zOiXpP+l^f6aH}!n%uwmA1&{mH={TzS5==-cFO*H}hM=AC&Lg*P}pF#dFnD`S; z{SAe-`4kG2+B00000 diff --git a/backend/__pycache__/routes.cpython-311.pyc b/backend/__pycache__/routes.cpython-311.pyc deleted file mode 100644 index ec6bf6357f9aaa45f4131fe41fdc61ab4fcbd109..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16162 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 n5+fn=?^yZ2V)~{Ak=C