Harisri commited on
Commit
fc895f4
·
1 Parent(s): 0aa78a2

Purged CV model deployment

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +3 -0
  2. Dockerfile +46 -0
  3. api.py +90 -0
  4. endToEnd2.py +93 -0
  5. frontend/.gitignore +24 -0
  6. frontend/README.md +16 -0
  7. frontend/eslint.config.js +21 -0
  8. frontend/index.html +13 -0
  9. frontend/package-lock.json +0 -0
  10. frontend/package.json +31 -0
  11. frontend/public/bg-video.mp4 +3 -0
  12. frontend/public/favicon.svg +1 -0
  13. frontend/public/icons.svg +24 -0
  14. frontend/src/App.css +184 -0
  15. frontend/src/App.jsx +160 -0
  16. frontend/src/assets/hero.png +3 -0
  17. frontend/src/assets/react.svg +1 -0
  18. frontend/src/assets/vite.svg +1 -0
  19. frontend/src/index.css +343 -0
  20. frontend/src/main.jsx +10 -0
  21. frontend/vite.config.js +7 -0
  22. models/best.pt +3 -0
  23. requirements.txt +39 -0
  24. samples/18_png.rf.4956b6043e9f9f738808088cfe37243d.jpg +3 -0
  25. samples/19_png.rf.5435466b5cc5a5cf9cbc1da0f911767b.jpg +3 -0
  26. samples/floorplan1.png +3 -0
  27. samples/floorplan2.png +3 -0
  28. samples/sample3.png +3 -0
  29. samples/upload_1ea7884c.jpg +3 -0
  30. samples/upload_6bc6cd93.jpg +3 -0
  31. samples/upload_7cfecab9.jpg +3 -0
  32. samples/upload_81a83d93.jpg +3 -0
  33. samples/upload_a931bf10.jpg +3 -0
  34. samples/upload_adad9a2a.jpg +3 -0
  35. samples/upload_b3613f65.jpg +3 -0
  36. samples/upload_b7f8874a.jpg +3 -0
  37. samples/upload_d5875a8c.jpg +3 -0
  38. samples/upload_e3e1e653.jpg +3 -0
  39. samples/upload_e8616a4f.jpg +3 -0
  40. samples/upload_ecd85a2a.jpg +3 -0
  41. src/detection/__init__.py +19 -0
  42. src/detection/refinement.py +1184 -0
  43. src/geometry/__init__.py +0 -0
  44. src/geometry/pipeline.py +372 -0
  45. src/geometry/room_graph.py +298 -0
  46. src/geometry/scale_estimator.py +262 -0
  47. src/geometry/wall_vectorizer.py +294 -0
  48. src/preprocessing/__init__.py +0 -0
  49. src/preprocessing/binarizer.py +122 -0
  50. src/preprocessing/loader.py +107 -0
.gitattributes CHANGED
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.mp4 filter=lfs diff=lfs merge=lfs -text
37
+ *.png filter=lfs diff=lfs merge=lfs -text
38
+ *.jpg filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Stage 1: Build Frontend
2
+ FROM node:20-slim AS frontend-build
3
+ WORKDIR /app/frontend
4
+ COPY frontend/package*.json ./
5
+ RUN npm install
6
+ COPY frontend/ ./
7
+ RUN npm run build
8
+
9
+ # Stage 2: Final Image
10
+ FROM python:3.10-slim
11
+ WORKDIR /app
12
+
13
+ # Install system dependencies
14
+ RUN apt-get update && apt-get install -y \
15
+ libgl1 \
16
+ libglib2.0-0 \
17
+ tesseract-ocr \
18
+ && rm -rf /var/lib/apt/lists/*
19
+
20
+ # Install Python dependencies
21
+ COPY requirements.txt .
22
+ RUN pip install --no-cache-dir -r requirements.txt
23
+
24
+ # Copy backend code and modules
25
+ COPY src/ ./src/
26
+ COPY models/ ./models/
27
+ COPY api.py .
28
+ COPY endToEnd2.py .
29
+
30
+ # Copy built frontend from stage 1
31
+ COPY --from=frontend-build /app/frontend/dist ./frontend/dist
32
+
33
+ # Set environment variables
34
+ ENV PORT=7860
35
+ ENV MPLBACKEND=Agg
36
+ ENV HOME=/tmp
37
+
38
+ # Create necessary directories and set permissions
39
+ RUN mkdir -p samples generated_models outputs && \
40
+ chmod -R 777 samples generated_models outputs && \
41
+ chmod -R 777 /app
42
+
43
+ EXPOSE 7860
44
+
45
+ # Run the app
46
+ CMD ["python", "api.py"]
api.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, File, UploadFile, HTTPException
2
+ from fastapi.responses import FileResponse, JSONResponse
3
+ from fastapi.staticfiles import StaticFiles
4
+ from fastapi.middleware.cors import CORSMiddleware
5
+ import os
6
+ import shutil
7
+ from pathlib import Path
8
+ import uuid
9
+ import sys
10
+
11
+ # Import the existing pipeline
12
+ from endToEnd2 import run_pipeline
13
+
14
+ app = FastAPI(title="Floor2Model API")
15
+
16
+ app.add_middleware(
17
+ CORSMiddleware,
18
+ allow_origins=["*"],
19
+ allow_credentials=True,
20
+ allow_methods=["*"],
21
+ allow_headers=["*"],
22
+ )
23
+
24
+ PROJECT_ROOT = Path(__file__).resolve().parent
25
+ GENERATED_DIR = PROJECT_ROOT / "generated_models"
26
+ SAMPLES_DIR = PROJECT_ROOT / "samples"
27
+ FRONTEND_DIST = PROJECT_ROOT / "frontend" / "dist"
28
+
29
+ SAMPLES_DIR.mkdir(exist_ok=True)
30
+ GENERATED_DIR.mkdir(exist_ok=True)
31
+
32
+ # Mount static files to serve the generated models directly
33
+ app.mount("/generated_models", StaticFiles(directory=str(GENERATED_DIR)), name="generated_models")
34
+
35
+ @app.post("/upload")
36
+ async def upload_image(file: UploadFile = File(...)):
37
+ try:
38
+ # Generate a unique ID for this upload
39
+ file_id = str(uuid.uuid4())[:8]
40
+ file_ext = Path(file.filename).suffix
41
+ if not file_ext:
42
+ file_ext = ".png"
43
+
44
+ stem = f"upload_{file_id}"
45
+ save_path = SAMPLES_DIR / f"{stem}{file_ext}"
46
+
47
+ with open(save_path, "wb") as buffer:
48
+ shutil.copyfileobj(file.file, buffer)
49
+
50
+ return {"status": "success", "id": stem, "filename": file.filename}
51
+ except Exception as e:
52
+ raise HTTPException(status_code=500, detail=str(e))
53
+
54
+ @app.post("/process/{stem}")
55
+ async def process_image(stem: str):
56
+ # Find the file in samples
57
+ sample_files = list(SAMPLES_DIR.glob(f"{stem}.*"))
58
+ if not sample_files:
59
+ raise HTTPException(status_code=404, detail="File not found")
60
+
61
+ sample_image = sample_files[0]
62
+ out_dir = GENERATED_DIR / stem
63
+
64
+ try:
65
+ # Run the actual pipeline
66
+ run_pipeline(sample_image)
67
+
68
+ if not out_dir.exists() or not list(out_dir.glob("*")):
69
+ raise HTTPException(status_code=500, detail="Processing failed to generate output")
70
+
71
+ return {
72
+ "status": "success",
73
+ "results": {
74
+ "detections": f"/generated_models/{stem}/{stem}_detections.png",
75
+ "gltf": f"/generated_models/{stem}/{stem}.gltf",
76
+ "obj": f"/generated_models/{stem}/{stem}.obj",
77
+ }
78
+ }
79
+
80
+ except Exception as e:
81
+ print(f"Error processing {stem}: {e}")
82
+ raise HTTPException(status_code=500, detail=str(e))
83
+
84
+ # Serve frontend
85
+ if FRONTEND_DIST.exists():
86
+ app.mount("/", StaticFiles(directory=str(FRONTEND_DIST), html=True), name="frontend")
87
+
88
+ if __name__ == "__main__":
89
+ import uvicorn
90
+ uvicorn.run(app, host="0.0.0.0", port=7860)
endToEnd2.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cv2
2
+ import os
3
+ import sys
4
+ import shutil
5
+ from pathlib import Path
6
+
7
+ PROJECT_ROOT = Path(__file__).resolve().parent
8
+ os.chdir(PROJECT_ROOT)
9
+ sys.path.insert(0, str(PROJECT_ROOT))
10
+
11
+ from src.segmentation.predictor import FloorPlanPredictor
12
+ from src.segmentation.visualizer import SegmentationVisualizer
13
+ from src.geometry.pipeline import GeometryPipeline
14
+ from src.reconstruction.pipeline import ReconstructionPipeline
15
+
16
+ # Auto-detect device
17
+ import torch
18
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
19
+ print(f"Using device: {DEVICE}")
20
+
21
+ MODEL_PATH = PROJECT_ROOT / "models" / "best.pt"
22
+ OUTPUTS_DIR = PROJECT_ROOT / "outputs"
23
+ GENERATED_DIR = PROJECT_ROOT / "generated_models"
24
+
25
+ def run_pipeline(sample_image: Path):
26
+ stem = sample_image.stem
27
+
28
+ print(f"\n{'='*60}")
29
+ print(f"Processing: {sample_image.name}")
30
+ print(f"{'='*60}")
31
+
32
+ # ── Phase 2: Segmentation ──────────────────────────────────────────
33
+ print(f"\n[Phase 2] Segmentation on {DEVICE}...")
34
+ best_conf = None
35
+ best_result = None
36
+
37
+ for conf in [0.35, 0.25, 0.15, 0.10, 0.05]:
38
+ predictor = FloorPlanPredictor(str(MODEL_PATH), confidence=conf, device=DEVICE)
39
+ seg_result = predictor.predict(str(sample_image))
40
+ total = seg_result.summary["total_elements"]
41
+ if total > 0 and best_result is None:
42
+ best_conf = conf
43
+ best_result = seg_result
44
+
45
+ base_img = cv2.imread(str(sample_image), cv2.IMREAD_COLOR)
46
+ out_dir = GENERATED_DIR / stem
47
+ if out_dir.exists():
48
+ shutil.rmtree(out_dir)
49
+ out_dir.mkdir(parents=True)
50
+
51
+ ann_path = out_dir / f"{stem}_detections.png"
52
+
53
+ if best_result is None:
54
+ print(" ⚠ No elements detected.")
55
+ if base_img is not None:
56
+ cv2.imwrite(str(ann_path), base_img)
57
+ return
58
+
59
+ viz = SegmentationVisualizer()
60
+ annotated = viz.draw(base_img, best_result)
61
+ cv2.imwrite(str(ann_path), annotated)
62
+
63
+ # ── Phase 3: Geometry ─────────────────────────────────────────────
64
+ print("\n[Phase 3] Geometry reconstruction...")
65
+ img = cv2.imread(str(sample_image))
66
+ geo_result, _, _ = GeometryPipeline().run_and_visualize(
67
+ best_result, img,
68
+ image_path=str(sample_image),
69
+ output_dir=str(OUTPUTS_DIR / "geometry"),
70
+ )
71
+
72
+ # ── Phase 4: 3D Reconstruction ────────────────────────────────────
73
+ print("\n[Phase 4] 3D reconstruction...")
74
+
75
+ has_polygons = len(geo_result.vectorization.all_polygons) > 0
76
+ if not has_polygons:
77
+ print(" ⚠ No geometry to extrude — skipping 3D.")
78
+ return
79
+
80
+ model_3d = ReconstructionPipeline().reconstruct(
81
+ geo_result,
82
+ output_dir=str(out_dir),
83
+ stem=stem,
84
+ floorplan_image_path=str(sample_image),
85
+ render_image_path=None
86
+ )
87
+ print(f" ✓ {model_3d.summary}")
88
+
89
+ if __name__ == "__main__":
90
+ samples_dir = PROJECT_ROOT / "samples"
91
+ samples = sorted(list(samples_dir.glob("*.png")) + list(samples_dir.glob("*.jpg")))
92
+ for sample in samples:
93
+ run_pipeline(sample)
frontend/.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
frontend/README.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # React + Vite
2
+
3
+ This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4
+
5
+ Currently, two official plugins are available:
6
+
7
+ - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
8
+ - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
9
+
10
+ ## React Compiler
11
+
12
+ The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
13
+
14
+ ## Expanding the ESLint configuration
15
+
16
+ If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
frontend/eslint.config.js ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import reactHooks from 'eslint-plugin-react-hooks'
4
+ import reactRefresh from 'eslint-plugin-react-refresh'
5
+ import { defineConfig, globalIgnores } from 'eslint/config'
6
+
7
+ export default defineConfig([
8
+ globalIgnores(['dist']),
9
+ {
10
+ files: ['**/*.{js,jsx}'],
11
+ extends: [
12
+ js.configs.recommended,
13
+ reactHooks.configs.flat.recommended,
14
+ reactRefresh.configs.vite,
15
+ ],
16
+ languageOptions: {
17
+ globals: globals.browser,
18
+ parserOptions: { ecmaFeatures: { jsx: true } },
19
+ },
20
+ },
21
+ ])
frontend/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>frontend</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.jsx"></script>
12
+ </body>
13
+ </html>
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "frontend",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "lint": "eslint .",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "@google/model-viewer": "^4.2.0",
14
+ "axios": "^1.15.2",
15
+ "framer-motion": "^12.38.0",
16
+ "lucide-react": "^1.11.0",
17
+ "react": "^19.2.5",
18
+ "react-dom": "^19.2.5"
19
+ },
20
+ "devDependencies": {
21
+ "@eslint/js": "^10.0.1",
22
+ "@types/react": "^19.2.14",
23
+ "@types/react-dom": "^19.2.3",
24
+ "@vitejs/plugin-react": "^6.0.1",
25
+ "eslint": "^10.2.1",
26
+ "eslint-plugin-react-hooks": "^7.1.1",
27
+ "eslint-plugin-react-refresh": "^0.5.2",
28
+ "globals": "^17.5.0",
29
+ "vite": "^8.0.10"
30
+ }
31
+ }
frontend/public/bg-video.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e932a47117072aa9190e723e678510ff1758e329901bf60780c5f29d6cd3a945
3
+ size 8875980
frontend/public/favicon.svg ADDED
frontend/public/icons.svg ADDED
frontend/src/App.css ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .counter {
2
+ font-size: 16px;
3
+ padding: 5px 10px;
4
+ border-radius: 5px;
5
+ color: var(--accent);
6
+ background: var(--accent-bg);
7
+ border: 2px solid transparent;
8
+ transition: border-color 0.3s;
9
+ margin-bottom: 24px;
10
+
11
+ &:hover {
12
+ border-color: var(--accent-border);
13
+ }
14
+ &:focus-visible {
15
+ outline: 2px solid var(--accent);
16
+ outline-offset: 2px;
17
+ }
18
+ }
19
+
20
+ .hero {
21
+ position: relative;
22
+
23
+ .base,
24
+ .framework,
25
+ .vite {
26
+ inset-inline: 0;
27
+ margin: 0 auto;
28
+ }
29
+
30
+ .base {
31
+ width: 170px;
32
+ position: relative;
33
+ z-index: 0;
34
+ }
35
+
36
+ .framework,
37
+ .vite {
38
+ position: absolute;
39
+ }
40
+
41
+ .framework {
42
+ z-index: 1;
43
+ top: 34px;
44
+ height: 28px;
45
+ transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
46
+ scale(1.4);
47
+ }
48
+
49
+ .vite {
50
+ z-index: 0;
51
+ top: 107px;
52
+ height: 26px;
53
+ width: auto;
54
+ transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
55
+ scale(0.8);
56
+ }
57
+ }
58
+
59
+ #center {
60
+ display: flex;
61
+ flex-direction: column;
62
+ gap: 25px;
63
+ place-content: center;
64
+ place-items: center;
65
+ flex-grow: 1;
66
+
67
+ @media (max-width: 1024px) {
68
+ padding: 32px 20px 24px;
69
+ gap: 18px;
70
+ }
71
+ }
72
+
73
+ #next-steps {
74
+ display: flex;
75
+ border-top: 1px solid var(--border);
76
+ text-align: left;
77
+
78
+ & > div {
79
+ flex: 1 1 0;
80
+ padding: 32px;
81
+ @media (max-width: 1024px) {
82
+ padding: 24px 20px;
83
+ }
84
+ }
85
+
86
+ .icon {
87
+ margin-bottom: 16px;
88
+ width: 22px;
89
+ height: 22px;
90
+ }
91
+
92
+ @media (max-width: 1024px) {
93
+ flex-direction: column;
94
+ text-align: center;
95
+ }
96
+ }
97
+
98
+ #docs {
99
+ border-right: 1px solid var(--border);
100
+
101
+ @media (max-width: 1024px) {
102
+ border-right: none;
103
+ border-bottom: 1px solid var(--border);
104
+ }
105
+ }
106
+
107
+ #next-steps ul {
108
+ list-style: none;
109
+ padding: 0;
110
+ display: flex;
111
+ gap: 8px;
112
+ margin: 32px 0 0;
113
+
114
+ .logo {
115
+ height: 18px;
116
+ }
117
+
118
+ a {
119
+ color: var(--text-h);
120
+ font-size: 16px;
121
+ border-radius: 6px;
122
+ background: var(--social-bg);
123
+ display: flex;
124
+ padding: 6px 12px;
125
+ align-items: center;
126
+ gap: 8px;
127
+ text-decoration: none;
128
+ transition: box-shadow 0.3s;
129
+
130
+ &:hover {
131
+ box-shadow: var(--shadow);
132
+ }
133
+ .button-icon {
134
+ height: 18px;
135
+ width: 18px;
136
+ }
137
+ }
138
+
139
+ @media (max-width: 1024px) {
140
+ margin-top: 20px;
141
+ flex-wrap: wrap;
142
+ justify-content: center;
143
+
144
+ li {
145
+ flex: 1 1 calc(50% - 8px);
146
+ }
147
+
148
+ a {
149
+ width: 100%;
150
+ justify-content: center;
151
+ box-sizing: border-box;
152
+ }
153
+ }
154
+ }
155
+
156
+ #spacer {
157
+ height: 88px;
158
+ border-top: 1px solid var(--border);
159
+ @media (max-width: 1024px) {
160
+ height: 48px;
161
+ }
162
+ }
163
+
164
+ .ticks {
165
+ position: relative;
166
+ width: 100%;
167
+
168
+ &::before,
169
+ &::after {
170
+ content: '';
171
+ position: absolute;
172
+ top: -4.5px;
173
+ border: 5px solid transparent;
174
+ }
175
+
176
+ &::before {
177
+ left: 0;
178
+ border-left-color: var(--border);
179
+ }
180
+ &::after {
181
+ right: 0;
182
+ border-right-color: var(--border);
183
+ }
184
+ }
frontend/src/App.jsx ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { motion, AnimatePresence } from 'framer-motion';
3
+ import { UploadCloud, Layers, Cuboid, RotateCw, CheckCircle2 } from 'lucide-react';
4
+ import axios from 'axios';
5
+ import '@google/model-viewer';
6
+
7
+ const API_BASE = ''; // Use relative paths for co-located serving
8
+
9
+ function App() {
10
+ const [file, setFile] = useState(null);
11
+ const [status, setStatus] = useState('landing'); // landing, idle, uploading, processing, done, error
12
+ const [results, setResults] = useState(null);
13
+ const [errorMsg, setErrorMsg] = useState('');
14
+
15
+ const handleDrop = (e) => {
16
+ e.preventDefault();
17
+ const droppedFile = e.dataTransfer.files[0];
18
+ if (droppedFile && droppedFile.type.startsWith('image/')) {
19
+ setFile(droppedFile);
20
+ }
21
+ };
22
+
23
+ const handleUpload = async () => {
24
+ if (!file) return;
25
+
26
+ try {
27
+ setStatus('uploading');
28
+ const formData = new FormData();
29
+ formData.append('file', file);
30
+
31
+ const uploadRes = await axios.post(`${API_BASE}/upload`, formData);
32
+ const stem = uploadRes.data.id;
33
+
34
+ setStatus('processing');
35
+ const processRes = await axios.post(`${API_BASE}/process/${stem}`, {}, { timeout: 600000 });
36
+
37
+ setResults(processRes.data.results);
38
+ setStatus('done');
39
+ } catch (error) {
40
+ console.error(error);
41
+ setErrorMsg(error.response?.data?.detail || error.message);
42
+ setStatus('error');
43
+ }
44
+ };
45
+
46
+ const reset = () => {
47
+ setFile(null);
48
+ setResults(null);
49
+ setStatus('idle');
50
+ setErrorMsg('');
51
+ };
52
+
53
+ return (
54
+ <>
55
+ <video
56
+ autoPlay
57
+ loop
58
+ muted
59
+ playsInline
60
+ className={`bg-video ${(status === 'processing' || status === 'done') ? 'hidden' : ''}`}
61
+ >
62
+ <source src="/bg-video.mp4" type="video/mp4" />
63
+ </video>
64
+
65
+ <div className="app-container">
66
+ {status !== 'landing' && (
67
+ <motion.header
68
+ initial={{ opacity: 0, y: -20 }}
69
+ animate={{ opacity: 1, y: 0 }}
70
+ transition={{ duration: 0.8 }}
71
+ >
72
+ <h1>Floor2Model</h1>
73
+ <p className="subtitle">
74
+ Transform 2D floor plans into interactive 3D environments instantly.
75
+ </p>
76
+ </motion.header>
77
+ )}
78
+
79
+ <main>
80
+ <AnimatePresence mode="wait">
81
+ {status === 'landing' ? (
82
+ <motion.div
83
+ key="landing"
84
+ initial={{ opacity: 0 }}
85
+ animate={{ opacity: 1 }}
86
+ exit={{ opacity: 0 }}
87
+ className="landing-container"
88
+ >
89
+ <h1 className="landing-title">Floor2Plan</h1>
90
+ <p className="landing-description">
91
+ Upload any 2D blueprint and instantly reconstruct a fully interactive 3D model.
92
+ </p>
93
+ <button className="landing-btn" onClick={() => setStatus('idle')}>
94
+ Enter Studio
95
+ </button>
96
+ </motion.div>
97
+ ) : status === 'idle' || status === 'uploading' || status === 'error' ? (
98
+ <motion.div
99
+ key="upload"
100
+ initial={{ opacity: 0, scale: 0.9 }}
101
+ animate={{ opacity: 1, scale: 1 }}
102
+ className="dropzone-container"
103
+ >
104
+ <div
105
+ className={`dropzone glass-panel ${file ? 'active' : ''}`}
106
+ onDragOver={(e) => e.preventDefault()}
107
+ onDrop={handleDrop}
108
+ onClick={() => document.getElementById('file-upload').click()}
109
+ >
110
+ <input type="file" id="file-upload" hidden onChange={(e) => setFile(e.target.files[0])} />
111
+ {file ? <CheckCircle2 size={60} color="#10b981" /> : <UploadCloud size={60} color="#6366f1" />}
112
+ <p className="dropzone-text">{file ? file.name : "Drop floorplan here"}</p>
113
+ </div>
114
+
115
+ <div style={{ display: 'flex', justifyContent: 'center', marginTop: '2rem' }}>
116
+ <button className="btn" onClick={handleUpload} disabled={!file || status === 'uploading'}>
117
+ {status === 'uploading' ? <RotateCw className="animate-spin" /> : "Reconstruct"}
118
+ </button>
119
+ </div>
120
+ {errorMsg && <p style={{ color: '#ef4444', textAlign: 'center', marginTop: '1rem' }}>{errorMsg}</p>}
121
+ </motion.div>
122
+ ) : status === 'processing' ? (
123
+ <motion.div key="processing" className="loader-container">
124
+ <div className="spinner"></div>
125
+ <p>Analyzing geometry and generating mesh...</p>
126
+ </motion.div>
127
+ ) : (
128
+ <motion.div key="results" className="results-container">
129
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
130
+ <h2>Reconstruction Complete</h2>
131
+ <button className="btn" onClick={reset}>New Project</button>
132
+ </div>
133
+
134
+ <div className="result-card glass-panel viewer-3d">
135
+ <model-viewer
136
+ src={`${API_BASE}${results?.gltf}`}
137
+ camera-controls auto-rotate shadow-intensity="1"
138
+ style={{ width: '100%', height: '600px' }}
139
+ ></model-viewer>
140
+ </div>
141
+
142
+ <div className="results-grid" style={{ gridTemplateColumns: '1fr' }}>
143
+ <div className="result-card glass-panel">
144
+ <div className="result-card-header">
145
+ <Layers size={20} color="var(--secondary)" />
146
+ <span className="result-card-title">Detection Map</span>
147
+ </div>
148
+ <img src={`${API_BASE}${results?.detections}`} alt="Map" style={{ width: '100%', borderRadius: '8px' }} />
149
+ </div>
150
+ </div>
151
+ </motion.div>
152
+ )}
153
+ </AnimatePresence>
154
+ </main>
155
+ </div>
156
+ </>
157
+ );
158
+ }
159
+
160
+ export default App;
frontend/src/assets/hero.png ADDED

Git LFS Details

  • SHA256: 881ffbcaafc212e49addad08846a5b82761355fa20624253af3477ba33262c5c
  • Pointer size: 130 Bytes
  • Size of remote file: 13.1 kB
frontend/src/assets/react.svg ADDED
frontend/src/assets/vite.svg ADDED
frontend/src/index.css ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap');
2
+
3
+ :root {
4
+ --bg-color: #050505;
5
+ --text-color: #f1f1f1;
6
+ --primary: #6366f1;
7
+ --primary-glow: rgba(99, 102, 241, 0.5);
8
+ --secondary: #ec4899;
9
+ --glass-bg: rgba(255, 255, 255, 0.03);
10
+ --glass-border: rgba(255, 255, 255, 0.05);
11
+ --glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
12
+ --radius: 16px;
13
+ }
14
+
15
+ * {
16
+ margin: 0;
17
+ padding: 0;
18
+ box-sizing: border-box;
19
+ }
20
+
21
+ body {
22
+ font-family: 'Outfit', sans-serif;
23
+ background-color: var(--bg-color);
24
+ color: var(--text-color);
25
+ min-height: 100vh;
26
+ overflow-x: hidden;
27
+ background-image:
28
+ radial-gradient(circle at 15% 50%, rgba(99, 102, 241, 0.15), transparent 25%),
29
+ radial-gradient(circle at 85% 30%, rgba(236, 72, 153, 0.15), transparent 25%);
30
+ background-attachment: fixed;
31
+ }
32
+
33
+ .glass-panel {
34
+ background: var(--glass-bg);
35
+ backdrop-filter: blur(12px);
36
+ -webkit-backdrop-filter: blur(12px);
37
+ border: 1px solid var(--glass-border);
38
+ border-radius: var(--radius);
39
+ box-shadow: var(--glass-shadow);
40
+ }
41
+
42
+ .app-container {
43
+ max-width: 1200px;
44
+ margin: 0 auto;
45
+ padding: 2rem;
46
+ display: flex;
47
+ flex-direction: column;
48
+ gap: 3rem;
49
+ }
50
+
51
+ /* Header */
52
+ header {
53
+ text-align: center;
54
+ margin-top: 4rem;
55
+ margin-bottom: 2rem;
56
+ }
57
+
58
+ h1 {
59
+ font-size: 4rem;
60
+ font-weight: 700;
61
+ background: linear-gradient(135deg, #fff 0%, #a5a5a5 100%);
62
+ -webkit-background-clip: text;
63
+ -webkit-text-fill-color: transparent;
64
+ margin-bottom: 1rem;
65
+ letter-spacing: -1px;
66
+ }
67
+
68
+ .subtitle {
69
+ font-size: 1.2rem;
70
+ color: #a1a1aa;
71
+ max-width: 600px;
72
+ margin: 0 auto;
73
+ line-height: 1.6;
74
+ }
75
+
76
+ /* Dropzone */
77
+ .dropzone-container {
78
+ width: 100%;
79
+ max-width: 800px;
80
+ margin: 0 auto;
81
+ }
82
+
83
+ .dropzone {
84
+ display: flex;
85
+ flex-direction: column;
86
+ align-items: center;
87
+ justify-content: center;
88
+ padding: 4rem 2rem;
89
+ border: 2px dashed rgba(99, 102, 241, 0.3);
90
+ border-radius: var(--radius);
91
+ background: rgba(99, 102, 241, 0.02);
92
+ cursor: pointer;
93
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
94
+ position: relative;
95
+ overflow: hidden;
96
+ }
97
+
98
+ .dropzone:hover, .dropzone.active {
99
+ border-color: var(--primary);
100
+ background: rgba(99, 102, 241, 0.05);
101
+ box-shadow: 0 0 30px var(--primary-glow);
102
+ }
103
+
104
+ .dropzone-icon {
105
+ width: 64px;
106
+ height: 64px;
107
+ color: var(--primary);
108
+ margin-bottom: 1.5rem;
109
+ transition: transform 0.3s ease;
110
+ }
111
+
112
+ .dropzone:hover .dropzone-icon {
113
+ transform: translateY(-5px);
114
+ }
115
+
116
+ .dropzone-text {
117
+ font-size: 1.25rem;
118
+ font-weight: 500;
119
+ margin-bottom: 0.5rem;
120
+ }
121
+
122
+ .dropzone-subtext {
123
+ color: #71717a;
124
+ font-size: 0.9rem;
125
+ }
126
+
127
+ /* Results Grid */
128
+ .results-container {
129
+ display: flex;
130
+ flex-direction: column;
131
+ gap: 2rem;
132
+ }
133
+
134
+ .results-grid {
135
+ display: grid;
136
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
137
+ gap: 2rem;
138
+ }
139
+
140
+ .result-card {
141
+ display: flex;
142
+ flex-direction: column;
143
+ overflow: hidden;
144
+ position: relative;
145
+ transition: transform 0.3s ease;
146
+ }
147
+
148
+ .result-card:hover {
149
+ transform: translateY(-5px);
150
+ }
151
+
152
+ .result-card-header {
153
+ padding: 1.25rem;
154
+ border-bottom: 1px solid var(--glass-border);
155
+ display: flex;
156
+ align-items: center;
157
+ gap: 0.75rem;
158
+ }
159
+
160
+ .result-card-title {
161
+ font-size: 1.1rem;
162
+ font-weight: 600;
163
+ }
164
+
165
+ .result-card-content {
166
+ flex: 1;
167
+ min-height: 300px;
168
+ display: flex;
169
+ align-items: center;
170
+ justify-content: center;
171
+ position: relative;
172
+ background: rgba(0, 0, 0, 0.2);
173
+ }
174
+
175
+ .result-image {
176
+ width: 100%;
177
+ height: 100%;
178
+ object-fit: contain;
179
+ padding: 1rem;
180
+ }
181
+
182
+ /* 3D Viewer specific */
183
+ .viewer-3d {
184
+ grid-column: 1 / -1;
185
+ min-height: 500px;
186
+ }
187
+
188
+ model-viewer {
189
+ width: 100%;
190
+ height: 100%;
191
+ --poster-color: transparent;
192
+ }
193
+
194
+ /* Buttons */
195
+ .btn {
196
+ background: linear-gradient(135deg, var(--primary) 0%, #4f46e5 100%);
197
+ color: white;
198
+ border: none;
199
+ padding: 0.75rem 1.5rem;
200
+ border-radius: 8px;
201
+ font-family: inherit;
202
+ font-weight: 600;
203
+ font-size: 1rem;
204
+ cursor: pointer;
205
+ transition: all 0.3s ease;
206
+ box-shadow: 0 4px 15px rgba(99, 102, 241, 0.4);
207
+ display: flex;
208
+ align-items: center;
209
+ gap: 0.5rem;
210
+ }
211
+
212
+ .btn:hover:not(:disabled) {
213
+ transform: translateY(-2px);
214
+ box-shadow: 0 6px 20px rgba(99, 102, 241, 0.6);
215
+ }
216
+
217
+ .btn:disabled {
218
+ opacity: 0.5;
219
+ cursor: not-allowed;
220
+ }
221
+
222
+ /* Loading State */
223
+ .loader-container {
224
+ display: flex;
225
+ flex-direction: column;
226
+ align-items: center;
227
+ justify-content: center;
228
+ padding: 4rem 0;
229
+ gap: 2rem;
230
+ }
231
+
232
+ .spinner {
233
+ width: 60px;
234
+ height: 60px;
235
+ border: 4px solid rgba(255, 255, 255, 0.1);
236
+ border-left-color: var(--primary);
237
+ border-radius: 50%;
238
+ animation: spin 1s linear infinite;
239
+ }
240
+
241
+ @keyframes spin {
242
+ to { transform: rotate(360deg); }
243
+ }
244
+
245
+ .processing-text {
246
+ font-size: 1.5rem;
247
+ font-weight: 500;
248
+ background: linear-gradient(90deg, #fff, #a5a5a5, #fff);
249
+ background-size: 200% auto;
250
+ -webkit-background-clip: text;
251
+ -webkit-text-fill-color: transparent;
252
+ animation: shine 2s linear infinite;
253
+ }
254
+
255
+ @keyframes shine {
256
+ to { background-position: 200% center; }
257
+ }
258
+
259
+ /* Scrollbar */
260
+ ::-webkit-scrollbar {
261
+ width: 8px;
262
+ }
263
+ ::-webkit-scrollbar-track {
264
+ background: var(--bg-color);
265
+ }
266
+ ::-webkit-scrollbar-thumb {
267
+ background: rgba(255, 255, 255, 0.2);
268
+ border-radius: 4px;
269
+ }
270
+ ::-webkit-scrollbar-thumb:hover {
271
+ background: rgba(255, 255, 255, 0.4);
272
+ }
273
+
274
+ /* Video Background */
275
+ .bg-video {
276
+ position: fixed;
277
+ top: 0;
278
+ left: 0;
279
+ width: 100vw;
280
+ height: 100vh;
281
+ object-fit: cover;
282
+ z-index: -1;
283
+ opacity: 0.4;
284
+ transition: opacity 1s ease-in-out;
285
+ pointer-events: none;
286
+ }
287
+
288
+ .bg-video.hidden {
289
+ opacity: 0.05;
290
+ filter: blur(10px);
291
+ }
292
+
293
+ /* Landing Page */
294
+ .landing-container {
295
+ display: flex;
296
+ flex-direction: column;
297
+ align-items: center;
298
+ justify-content: center;
299
+ min-height: 80vh;
300
+ text-align: center;
301
+ gap: 2rem;
302
+ }
303
+
304
+ .landing-title {
305
+ font-size: 6rem;
306
+ font-weight: 800;
307
+ background: linear-gradient(135deg, #fff 0%, var(--primary) 100%);
308
+ -webkit-background-clip: text;
309
+ -webkit-text-fill-color: transparent;
310
+ margin-bottom: 0.5rem;
311
+ letter-spacing: -2px;
312
+ filter: drop-shadow(0 4px 20px rgba(99, 102, 241, 0.3));
313
+ }
314
+
315
+ .landing-description {
316
+ font-size: 1.5rem;
317
+ color: #e4e4e7;
318
+ max-width: 800px;
319
+ line-height: 1.6;
320
+ margin-bottom: 2rem;
321
+ text-shadow: 0 2px 10px rgba(0,0,0,0.5);
322
+ }
323
+
324
+ .landing-btn {
325
+ font-size: 1.25rem;
326
+ padding: 1rem 3rem;
327
+ border-radius: 50px;
328
+ background: rgba(255, 255, 255, 0.1);
329
+ backdrop-filter: blur(10px);
330
+ border: 1px solid rgba(255, 255, 255, 0.2);
331
+ color: white;
332
+ cursor: pointer;
333
+ transition: all 0.3s ease;
334
+ font-weight: 600;
335
+ box-shadow: 0 10px 30px rgba(0,0,0,0.3);
336
+ }
337
+
338
+ .landing-btn:hover {
339
+ background: var(--primary);
340
+ border-color: var(--primary);
341
+ transform: translateY(-3px) scale(1.05);
342
+ box-shadow: 0 15px 40px var(--primary-glow);
343
+ }
frontend/src/main.jsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import './index.css'
4
+ import App from './App.jsx'
5
+
6
+ createRoot(document.getElementById('root')).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ )
frontend/vite.config.js ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ // https://vite.dev/config/
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ })
models/best.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:93629f31b48ac1083265bc957d746ba887c68e425e508e28aa715d8fa069297b
3
+ size 6260842
requirements.txt ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core API
2
+ fastapi
3
+ uvicorn
4
+ python-multipart
5
+
6
+ # Core image processing
7
+ opencv-python-headless>=4.8.0
8
+ numpy>=2.0.0
9
+ pillow>=9.0.0
10
+
11
+ # Visualization
12
+ matplotlib>=3.5.0
13
+
14
+ # OCR (optional — for scale estimation from dimension text)
15
+ pytesseract>=0.3.10
16
+
17
+ # Scientific / ML utilities
18
+ scipy>=1.7.0
19
+ scikit-learn>=1.0.0
20
+
21
+ # Deep learning
22
+ torch>=2.0.0
23
+ torchvision>=0.15.0
24
+
25
+ # YOLO segmentation
26
+ ultralytics>=8.0.0
27
+
28
+ # Progress bars
29
+ tqdm>=4.60.0
30
+
31
+ # Geometry processing
32
+ shapely>=2.0.0
33
+
34
+ # Room graph
35
+ networkx>=3.0
36
+
37
+ # 3D Reconstruction utilities
38
+ trimesh
39
+ pygltflib
samples/18_png.rf.4956b6043e9f9f738808088cfe37243d.jpg ADDED

Git LFS Details

  • SHA256: dd36b7fc249f9534506b0aa5b01067a5a18f6f3a799c00ff93ad7f296b49f69c
  • Pointer size: 130 Bytes
  • Size of remote file: 41 kB
samples/19_png.rf.5435466b5cc5a5cf9cbc1da0f911767b.jpg ADDED

Git LFS Details

  • SHA256: c55b996f0544ab24491b030da8fed2081636701c1aa73cc18e8eaa54c20308db
  • Pointer size: 130 Bytes
  • Size of remote file: 28.4 kB
samples/floorplan1.png ADDED

Git LFS Details

  • SHA256: db3e822411602893a817dfea3447de5ef376d9cb565c498f54625251b300fc18
  • Pointer size: 129 Bytes
  • Size of remote file: 3.11 kB
samples/floorplan2.png ADDED

Git LFS Details

  • SHA256: bd01cecc8f0676e8e3d93e132f3333b4c09dbfe7cae60b9f62c629d22e975da3
  • Pointer size: 131 Bytes
  • Size of remote file: 192 kB
samples/sample3.png ADDED

Git LFS Details

  • SHA256: 2709871ab698f499618f47a684183b9015731d72132d67eb4dc757182b7a0a5f
  • Pointer size: 131 Bytes
  • Size of remote file: 270 kB
samples/upload_1ea7884c.jpg ADDED

Git LFS Details

  • SHA256: c55b996f0544ab24491b030da8fed2081636701c1aa73cc18e8eaa54c20308db
  • Pointer size: 130 Bytes
  • Size of remote file: 28.4 kB
samples/upload_6bc6cd93.jpg ADDED

Git LFS Details

  • SHA256: c55b996f0544ab24491b030da8fed2081636701c1aa73cc18e8eaa54c20308db
  • Pointer size: 130 Bytes
  • Size of remote file: 28.4 kB
samples/upload_7cfecab9.jpg ADDED

Git LFS Details

  • SHA256: 0bcc9a6dbee699f098cf32824e3774c6f81ba8650c6213b3d8727b68f290c8f4
  • Pointer size: 130 Bytes
  • Size of remote file: 46.8 kB
samples/upload_81a83d93.jpg ADDED

Git LFS Details

  • SHA256: c55b996f0544ab24491b030da8fed2081636701c1aa73cc18e8eaa54c20308db
  • Pointer size: 130 Bytes
  • Size of remote file: 28.4 kB
samples/upload_a931bf10.jpg ADDED

Git LFS Details

  • SHA256: c55b996f0544ab24491b030da8fed2081636701c1aa73cc18e8eaa54c20308db
  • Pointer size: 130 Bytes
  • Size of remote file: 28.4 kB
samples/upload_adad9a2a.jpg ADDED

Git LFS Details

  • SHA256: 0bcc9a6dbee699f098cf32824e3774c6f81ba8650c6213b3d8727b68f290c8f4
  • Pointer size: 130 Bytes
  • Size of remote file: 46.8 kB
samples/upload_b3613f65.jpg ADDED

Git LFS Details

  • SHA256: c55b996f0544ab24491b030da8fed2081636701c1aa73cc18e8eaa54c20308db
  • Pointer size: 130 Bytes
  • Size of remote file: 28.4 kB
samples/upload_b7f8874a.jpg ADDED

Git LFS Details

  • SHA256: c55b996f0544ab24491b030da8fed2081636701c1aa73cc18e8eaa54c20308db
  • Pointer size: 130 Bytes
  • Size of remote file: 28.4 kB
samples/upload_d5875a8c.jpg ADDED

Git LFS Details

  • SHA256: 0bcc9a6dbee699f098cf32824e3774c6f81ba8650c6213b3d8727b68f290c8f4
  • Pointer size: 130 Bytes
  • Size of remote file: 46.8 kB
samples/upload_e3e1e653.jpg ADDED

Git LFS Details

  • SHA256: c55b996f0544ab24491b030da8fed2081636701c1aa73cc18e8eaa54c20308db
  • Pointer size: 130 Bytes
  • Size of remote file: 28.4 kB
samples/upload_e8616a4f.jpg ADDED

Git LFS Details

  • SHA256: c55b996f0544ab24491b030da8fed2081636701c1aa73cc18e8eaa54c20308db
  • Pointer size: 130 Bytes
  • Size of remote file: 28.4 kB
samples/upload_ecd85a2a.jpg ADDED

Git LFS Details

  • SHA256: c55b996f0544ab24491b030da8fed2081636701c1aa73cc18e8eaa54c20308db
  • Pointer size: 130 Bytes
  • Size of remote file: 28.4 kB
src/detection/__init__.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Detection refinement module for improving door and window detection.
3
+
4
+ This module provides geometry-based refinement of YOLO segmentation results,
5
+ using wall structure analysis and computer vision techniques to improve
6
+ detection accuracy for doors and windows in floor plans.
7
+ """
8
+
9
+ from .refinement import (
10
+ RefinementConfig,
11
+ DetectionRefiner,
12
+ refine_detections,
13
+ )
14
+
15
+ __all__ = [
16
+ 'RefinementConfig',
17
+ 'DetectionRefiner',
18
+ 'refine_detections',
19
+ ]
src/detection/refinement.py ADDED
@@ -0,0 +1,1184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Detection refinement module for improving door and window detection.
3
+
4
+ This module provides geometry-based refinement of YOLO segmentation results,
5
+ using wall structure, spatial relationships, and computer vision techniques.
6
+
7
+ Example usage:
8
+ >>> from src.detection.refinement import refine_detections, RefinementConfig
9
+ >>>
10
+ >>> # Use default configuration
11
+ >>> refined_result = refine_detections(yolo_output, image, geometry_output)
12
+ >>>
13
+ >>> # Use custom configuration
14
+ >>> config = RefinementConfig(
15
+ ... canny_low_threshold=40,
16
+ ... canny_high_threshold=160,
17
+ ... door_width=100
18
+ ... )
19
+ >>> refined_result = refine_detections(
20
+ ... yolo_output, image, geometry_output, config=config
21
+ ... )
22
+ """
23
+
24
+ from dataclasses import dataclass
25
+ from typing import Optional, List, Tuple
26
+ import logging
27
+
28
+ import numpy as np
29
+ import cv2
30
+
31
+ from src.segmentation.predictor import SegmentationResult
32
+ from src.geometry.wall_vectorizer import WallPolygon, VectorizationResult
33
+
34
+
35
+ # Configure logging
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ @dataclass
40
+ class RefinementConfig:
41
+ """Configuration for detection refinement algorithms."""
42
+
43
+ # Edge detection parameters (Canny)
44
+ canny_low_threshold: int = 50
45
+ canny_high_threshold: int = 150
46
+
47
+ # Line detection parameters (HoughLinesP)
48
+ hough_min_line_length: int = 30
49
+ hough_max_line_gap: int = 10
50
+ hough_threshold: int = 50
51
+
52
+ # Window detection parameters
53
+ parallel_line_proximity: int = 20 # max distance between parallel lines (px)
54
+ parallel_angle_tolerance: float = 15.0 # degrees
55
+ window_min_length: int = 30 # minimum window length (px)
56
+
57
+ # Door detection parameters
58
+ wall_gap_threshold: int = 20 # minimum gap width to consider as door (px)
59
+ room_adjacency_threshold: int = 50 # max distance for rooms to be adjacent (px)
60
+ door_width: int = 90 # standard door width in pixels (~0.9m)
61
+
62
+ # Spatial analysis parameters
63
+ wall_thickness_tolerance: int = 10 # tolerance for point-on-wall checks (px)
64
+
65
+ # Performance parameters
66
+ enable_door_detection: bool = True
67
+ enable_window_detection: bool = True
68
+
69
+ def __post_init__(self):
70
+ """Validate and clamp configuration parameters to reasonable ranges."""
71
+ # Validate Canny thresholds
72
+ self.canny_low_threshold = max(1, min(255, self.canny_low_threshold))
73
+ self.canny_high_threshold = max(1, min(255, self.canny_high_threshold))
74
+
75
+ if self.canny_high_threshold <= self.canny_low_threshold:
76
+ logger.warning(
77
+ f"Invalid Canny thresholds: low={self.canny_low_threshold}, "
78
+ f"high={self.canny_high_threshold}. Using defaults."
79
+ )
80
+ self.canny_low_threshold = 50
81
+ self.canny_high_threshold = 150
82
+
83
+ # Validate positive integer parameters
84
+ self.hough_min_line_length = max(1, self.hough_min_line_length)
85
+ self.hough_max_line_gap = max(0, self.hough_max_line_gap)
86
+ self.hough_threshold = max(1, self.hough_threshold)
87
+ self.parallel_line_proximity = max(1, self.parallel_line_proximity)
88
+ self.window_min_length = max(1, self.window_min_length)
89
+ self.wall_gap_threshold = max(1, self.wall_gap_threshold)
90
+ self.room_adjacency_threshold = max(1, self.room_adjacency_threshold)
91
+ self.door_width = max(1, self.door_width)
92
+ self.wall_thickness_tolerance = max(1, self.wall_thickness_tolerance)
93
+
94
+ # Validate angle tolerance
95
+ self.parallel_angle_tolerance = max(0.0, min(90.0, self.parallel_angle_tolerance))
96
+
97
+
98
+ # Type aliases for clarity
99
+ LineSegment = Tuple[Tuple[int, int], Tuple[int, int]] # ((x1, y1), (x2, y2))
100
+
101
+
102
+ class DetectionRefiner:
103
+ """Main orchestrator for detection refinement."""
104
+
105
+ def __init__(self, config: Optional[RefinementConfig] = None):
106
+ """
107
+ Initialize the detection refiner.
108
+
109
+ Parameters
110
+ ----------
111
+ config : RefinementConfig, optional
112
+ Configuration for refinement algorithms. If None, uses defaults.
113
+ """
114
+ self.config = config or RefinementConfig()
115
+ self.door_detector = DoorDetector(self.config)
116
+ self.window_detector = WindowDetector(self.config)
117
+
118
+ def refine(
119
+ self,
120
+ yolo_output: SegmentationResult,
121
+ image: np.ndarray,
122
+ geometry_output: VectorizationResult
123
+ ) -> VectorizationResult:
124
+ """
125
+ Main refinement function that replaces YOLO doors/windows
126
+ with geometry-based detections.
127
+
128
+ Parameters
129
+ ----------
130
+ yolo_output : SegmentationResult
131
+ Original YOLO segmentation result
132
+ image : np.ndarray
133
+ Preprocessed floor plan image (grayscale)
134
+ geometry_output : VectorizationResult
135
+ Vectorized geometry from Phase 3
136
+
137
+ Returns
138
+ -------
139
+ VectorizationResult
140
+ Refined VectorizationResult with improved doors/windows
141
+ """
142
+ logger.info("Starting detection refinement")
143
+
144
+ refined_doors = []
145
+ refined_windows = []
146
+
147
+ # Try door detection
148
+ if self.config.enable_door_detection:
149
+ try:
150
+ refined_doors = self.door_detector.detect(
151
+ geometry_output.walls,
152
+ geometry_output.rooms
153
+ )
154
+ logger.info(f"Door detection complete: {len(refined_doors)} doors detected")
155
+ except Exception as e:
156
+ logger.error(f"Door detection failed: {e}", exc_info=True)
157
+ logger.warning("Falling back to original YOLO door detections")
158
+ refined_doors = geometry_output.doors
159
+ else:
160
+ refined_doors = geometry_output.doors
161
+
162
+ # Try window detection independently
163
+ if self.config.enable_window_detection:
164
+ try:
165
+ refined_windows = self.window_detector.detect(
166
+ image,
167
+ geometry_output.walls
168
+ )
169
+ logger.info(f"Window detection complete: {len(refined_windows)} windows detected")
170
+ except Exception as e:
171
+ logger.error(f"Window detection failed: {e}", exc_info=True)
172
+ logger.warning("Falling back to original YOLO window detections")
173
+ refined_windows = geometry_output.windows
174
+ else:
175
+ refined_windows = geometry_output.windows
176
+
177
+ # Merge results
178
+ refined_result = VectorizationResult(
179
+ walls=geometry_output.walls,
180
+ rooms=geometry_output.rooms,
181
+ doors=refined_doors,
182
+ windows=refined_windows,
183
+ other=geometry_output.other,
184
+ image_shape=geometry_output.image_shape
185
+ )
186
+
187
+ logger.info(
188
+ f"Refinement complete: {len(refined_doors)} doors, "
189
+ f"{len(refined_windows)} windows"
190
+ )
191
+
192
+ return refined_result
193
+
194
+
195
+ def refine_detections(
196
+ yolo_output: SegmentationResult,
197
+ image: np.ndarray,
198
+ geometry_output: VectorizationResult,
199
+ config: Optional[RefinementConfig] = None
200
+ ) -> VectorizationResult:
201
+ """
202
+ Public API function for detection refinement.
203
+
204
+ This is the main entry point called by GeometryPipeline.
205
+
206
+ Parameters
207
+ ----------
208
+ yolo_output : SegmentationResult
209
+ Original YOLO segmentation result
210
+ image : np.ndarray
211
+ Preprocessed floor plan image (grayscale)
212
+ geometry_output : VectorizationResult
213
+ Vectorized geometry from Phase 3
214
+ config : RefinementConfig, optional
215
+ Optional custom configuration. If None, uses defaults.
216
+
217
+ Returns
218
+ -------
219
+ VectorizationResult
220
+ Refined VectorizationResult with improved doors/windows
221
+
222
+ Examples
223
+ --------
224
+ >>> refined = refine_detections(yolo_output, image, geometry_output)
225
+ >>> print(f"Detected {len(refined.doors)} doors")
226
+ """
227
+ try:
228
+ refiner = DetectionRefiner(config)
229
+ return refiner.refine(yolo_output, image, geometry_output)
230
+ except Exception as e:
231
+ logger.error(f"Refinement failed completely: {e}", exc_info=True)
232
+ logger.warning("Returning original geometry output unchanged")
233
+ return geometry_output
234
+
235
+
236
+ class SpatialAnalyzer:
237
+ """Analyzes spatial relationships between rooms and walls."""
238
+
239
+ def __init__(self, config: RefinementConfig):
240
+ """
241
+ Initialize spatial analyzer.
242
+
243
+ Parameters
244
+ ----------
245
+ config : RefinementConfig
246
+ Configuration for spatial analysis parameters
247
+ """
248
+ self.config = config
249
+
250
+ def find_adjacent_rooms(
251
+ self,
252
+ room_polygons: List[WallPolygon]
253
+ ) -> List[Tuple[WallPolygon, WallPolygon]]:
254
+ """
255
+ Find pairs of rooms that share a wall boundary.
256
+
257
+ Uses polygon proximity analysis: rooms are adjacent if their
258
+ boundaries come within room_adjacency_threshold pixels.
259
+
260
+ Parameters
261
+ ----------
262
+ room_polygons : List[WallPolygon]
263
+ List of room polygons
264
+
265
+ Returns
266
+ -------
267
+ List[Tuple[WallPolygon, WallPolygon]]
268
+ List of (room_a, room_b) tuples representing adjacent room pairs
269
+ """
270
+ if not room_polygons or len(room_polygons) < 2:
271
+ return []
272
+
273
+ adjacent_pairs = []
274
+ n = len(room_polygons)
275
+
276
+ for i in range(n):
277
+ for j in range(i + 1, n):
278
+ room_a = room_polygons[i]
279
+ room_b = room_polygons[j]
280
+
281
+ # Compute distance between room centroids
282
+ centroid_a = room_a.centroid
283
+ centroid_b = room_b.centroid
284
+ distance = np.sqrt(
285
+ (centroid_a[0] - centroid_b[0])**2 +
286
+ (centroid_a[1] - centroid_b[1])**2
287
+ )
288
+
289
+ # Check if rooms are close enough to be adjacent
290
+ if distance <= self.config.room_adjacency_threshold * 3: # Use 3x threshold for centroid distance
291
+ # Verify actual boundary proximity using minimum distance between polygon points
292
+ points_a = np.array(room_a.points, dtype=np.float32)
293
+ points_b = np.array(room_b.points, dtype=np.float32)
294
+
295
+ # Compute minimum distance between any two points
296
+ min_dist = float('inf')
297
+ for pt_a in points_a:
298
+ for pt_b in points_b:
299
+ dist = np.sqrt((pt_a[0] - pt_b[0])**2 + (pt_a[1] - pt_b[1])**2)
300
+ min_dist = min(min_dist, dist)
301
+
302
+ if min_dist <= self.config.room_adjacency_threshold:
303
+ adjacent_pairs.append((room_a, room_b))
304
+
305
+ logger.debug(f"Found {len(adjacent_pairs)} adjacent room pairs")
306
+ return adjacent_pairs
307
+
308
+ def find_shared_wall_segment(
309
+ self,
310
+ room_a: WallPolygon,
311
+ room_b: WallPolygon,
312
+ wall_polygons: List[WallPolygon]
313
+ ) -> Optional[np.ndarray]:
314
+ """
315
+ Extract the wall segment between two adjacent rooms.
316
+
317
+ Algorithm:
318
+ 1. Find the line connecting room centroids
319
+ 2. Find wall polygons that intersect this line
320
+ 3. Extract the portion of wall between the two rooms
321
+
322
+ Parameters
323
+ ----------
324
+ room_a : WallPolygon
325
+ First room polygon
326
+ room_b : WallPolygon
327
+ Second room polygon
328
+ wall_polygons : List[WallPolygon]
329
+ List of wall polygons
330
+
331
+ Returns
332
+ -------
333
+ np.ndarray or None
334
+ Nx2 numpy array of wall segment points, or None if not found
335
+ """
336
+ if not wall_polygons:
337
+ return None
338
+
339
+ centroid_a = np.array(room_a.centroid, dtype=np.float32)
340
+ centroid_b = np.array(room_b.centroid, dtype=np.float32)
341
+
342
+ # Find walls that lie between the two rooms
343
+ # A wall is between rooms if it's close to the line connecting centroids
344
+ candidate_walls = []
345
+
346
+ for wall in wall_polygons:
347
+ if not wall.is_wall:
348
+ continue
349
+
350
+ wall_centroid = np.array(wall.centroid, dtype=np.float32)
351
+
352
+ # Check if wall centroid is roughly between room centroids
353
+ # using dot product to check if it's in the same direction
354
+ vec_ab = centroid_b - centroid_a
355
+ vec_aw = wall_centroid - centroid_a
356
+
357
+ # Project wall centroid onto line AB
358
+ if np.dot(vec_ab, vec_ab) > 0:
359
+ t = np.dot(vec_aw, vec_ab) / np.dot(vec_ab, vec_ab)
360
+
361
+ # Wall should be between rooms (0 < t < 1)
362
+ if 0.2 < t < 0.8: # Allow some tolerance
363
+ # Check perpendicular distance to line
364
+ projection = centroid_a + t * vec_ab
365
+ perp_dist = np.linalg.norm(wall_centroid - projection)
366
+
367
+ if perp_dist < self.config.room_adjacency_threshold:
368
+ candidate_walls.append(wall)
369
+
370
+ if not candidate_walls:
371
+ logger.debug("No shared wall segment found between rooms")
372
+ return None
373
+
374
+ # Return the wall closest to the midpoint between rooms
375
+ midpoint = (centroid_a + centroid_b) / 2
376
+ closest_wall = min(
377
+ candidate_walls,
378
+ key=lambda w: np.linalg.norm(np.array(w.centroid) - midpoint)
379
+ )
380
+
381
+ return np.array(closest_wall.points, dtype=np.float32)
382
+
383
+ def is_point_on_wall(
384
+ self,
385
+ point: Tuple[float, float],
386
+ wall_polygon: WallPolygon
387
+ ) -> bool:
388
+ """
389
+ Check if a point lies within wall polygon boundaries.
390
+
391
+ Uses cv2.pointPolygonTest with tolerance from config.
392
+
393
+ Parameters
394
+ ----------
395
+ point : Tuple[float, float]
396
+ Point coordinates (x, y)
397
+ wall_polygon : WallPolygon
398
+ Wall polygon to test against
399
+
400
+ Returns
401
+ -------
402
+ bool
403
+ True if point is on or near the wall, False otherwise
404
+ """
405
+ wall_pts = np.array(wall_polygon.points, dtype=np.int32)
406
+
407
+ # Use cv2.pointPolygonTest to compute signed distance
408
+ # Positive = inside, 0 = on edge, negative = outside
409
+ dist = cv2.pointPolygonTest(wall_pts, point, measureDist=True)
410
+
411
+ # Point is on wall if distance is within tolerance
412
+ return abs(dist) <= self.config.wall_thickness_tolerance
413
+
414
+ def get_wall_direction(
415
+ self,
416
+ wall_segment: np.ndarray
417
+ ) -> float:
418
+ """
419
+ Compute orientation angle of a wall segment.
420
+
421
+ Uses PCA (principal component analysis) on wall points
422
+ to find dominant direction.
423
+
424
+ Parameters
425
+ ----------
426
+ wall_segment : np.ndarray
427
+ Nx2 array of wall segment points
428
+
429
+ Returns
430
+ -------
431
+ float
432
+ Angle in degrees (0-180 range)
433
+ """
434
+ if len(wall_segment) < 2:
435
+ logger.warning("Wall segment has fewer than 2 points, returning 0 degrees")
436
+ return 0.0
437
+
438
+ try:
439
+ # Use PCA to find principal direction
440
+ from sklearn.decomposition import PCA
441
+
442
+ pca = PCA(n_components=1)
443
+ pca.fit(wall_segment)
444
+
445
+ # Get principal component (dominant direction)
446
+ component = pca.components_[0]
447
+ angle = np.arctan2(component[1], component[0])
448
+
449
+ # Convert to degrees, normalize to [0, 180)
450
+ angle_deg = np.degrees(angle) % 180
451
+
452
+ return float(angle_deg)
453
+
454
+ except Exception as e:
455
+ logger.warning(f"Failed to compute wall direction using PCA: {e}")
456
+
457
+ # Fallback: use simple line fitting
458
+ try:
459
+ # Fit line using first and last points
460
+ p1 = wall_segment[0]
461
+ p2 = wall_segment[-1]
462
+
463
+ dx = p2[0] - p1[0]
464
+ dy = p2[1] - p1[1]
465
+
466
+ angle = np.arctan2(dy, dx)
467
+ angle_deg = np.degrees(angle) % 180
468
+
469
+ return float(angle_deg)
470
+
471
+ except Exception as e2:
472
+ logger.error(f"Failed to compute wall direction: {e2}")
473
+ return 0.0
474
+
475
+
476
+ class GapDetector:
477
+ """Detects gaps and discontinuities in wall segments."""
478
+
479
+ def __init__(self, config: RefinementConfig):
480
+ """
481
+ Initialize gap detector.
482
+
483
+ Parameters
484
+ ----------
485
+ config : RefinementConfig
486
+ Configuration for gap detection parameters
487
+ """
488
+ self.config = config
489
+
490
+ def detect_wall_gaps(
491
+ self,
492
+ wall_segment: np.ndarray
493
+ ) -> List[Tuple[np.ndarray, np.ndarray]]:
494
+ """
495
+ Identify breaks or discontinuities in a wall segment.
496
+
497
+ Algorithm:
498
+ 1. Sort wall points along the wall direction
499
+ 2. Compute distances between consecutive points
500
+ 3. Identify gaps where distance > wall_gap_threshold
501
+
502
+ Parameters
503
+ ----------
504
+ wall_segment : np.ndarray
505
+ Nx2 array of wall segment points
506
+
507
+ Returns
508
+ -------
509
+ List[Tuple[np.ndarray, np.ndarray]]
510
+ List of (start_point, end_point) tuples indicating gap locations
511
+ """
512
+ if len(wall_segment) < 2:
513
+ logger.debug("Wall segment too short for gap detection")
514
+ return []
515
+
516
+ try:
517
+ # Compute wall direction to sort points along it
518
+ centroid = np.mean(wall_segment, axis=0)
519
+
520
+ # Use PCA to find principal direction
521
+ from sklearn.decomposition import PCA
522
+ pca = PCA(n_components=1)
523
+ pca.fit(wall_segment)
524
+ direction = pca.components_[0]
525
+
526
+ # Project points onto principal direction
527
+ projections = np.dot(wall_segment - centroid, direction)
528
+
529
+ # Sort points by projection
530
+ sorted_indices = np.argsort(projections)
531
+ sorted_points = wall_segment[sorted_indices]
532
+
533
+ except Exception as e:
534
+ logger.warning(f"Failed to sort wall points using PCA: {e}, using simple sort")
535
+ # Fallback: sort by x-coordinate
536
+ sorted_indices = np.argsort(wall_segment[:, 0])
537
+ sorted_points = wall_segment[sorted_indices]
538
+
539
+ # Compute distances between consecutive points
540
+ gaps = []
541
+ for i in range(len(sorted_points) - 1):
542
+ p1 = sorted_points[i]
543
+ p2 = sorted_points[i + 1]
544
+
545
+ distance = np.linalg.norm(p2 - p1)
546
+
547
+ # If distance exceeds threshold, we found a gap
548
+ if distance > self.config.wall_gap_threshold:
549
+ gaps.append((p1, p2))
550
+ logger.debug(f"Found gap of width {distance:.1f}px at {p1} -> {p2}")
551
+
552
+ if not gaps:
553
+ logger.debug("No wall gaps detected")
554
+ else:
555
+ logger.debug(f"Detected {len(gaps)} wall gaps")
556
+
557
+ return gaps
558
+
559
+
560
+ # Placeholder classes - will be implemented in subsequent tasks
561
+ class DoorDetector:
562
+ """Detects doors based on wall gaps and room adjacency."""
563
+
564
+ def __init__(self, config: RefinementConfig):
565
+ self.config = config
566
+ self.spatial_analyzer = SpatialAnalyzer(config)
567
+ self.gap_detector = GapDetector(config)
568
+
569
+ def detect(
570
+ self,
571
+ wall_polygons: List[WallPolygon],
572
+ room_polygons: List[WallPolygon]
573
+ ) -> List[WallPolygon]:
574
+ """
575
+ Detect door locations from wall geometry and room adjacency.
576
+
577
+ Parameters
578
+ ----------
579
+ wall_polygons : List[WallPolygon]
580
+ List of vectorized wall polygons
581
+ room_polygons : List[WallPolygon]
582
+ List of vectorized room polygons
583
+
584
+ Returns
585
+ -------
586
+ List[WallPolygon]
587
+ List of door polygons with class_id=3, class_name="Door"
588
+ """
589
+ # Input validation
590
+ if not wall_polygons:
591
+ logger.warning("No wall polygons provided, skipping door detection")
592
+ return []
593
+
594
+ if not room_polygons:
595
+ logger.warning("No room polygons provided, skipping door detection")
596
+ return []
597
+
598
+ logger.info(f"Detecting doors from {len(wall_polygons)} walls and {len(room_polygons)} rooms")
599
+
600
+ doors = []
601
+
602
+ # Find all pairs of adjacent rooms
603
+ adjacent_pairs = self.spatial_analyzer.find_adjacent_rooms(room_polygons)
604
+
605
+ if not adjacent_pairs:
606
+ logger.info("No adjacent rooms found, no doors to place")
607
+ return []
608
+
609
+ logger.info(f"Found {len(adjacent_pairs)} adjacent room pairs")
610
+
611
+ # For each adjacent room pair, place a door
612
+ for room_a, room_b in adjacent_pairs:
613
+ try:
614
+ # Find the shared wall segment
615
+ shared_wall = self.spatial_analyzer.find_shared_wall_segment(
616
+ room_a, room_b, wall_polygons
617
+ )
618
+
619
+ if shared_wall is None:
620
+ logger.debug(f"No shared wall found between rooms, skipping")
621
+ continue
622
+
623
+ # Detect gaps in the wall
624
+ gaps = self.gap_detector.detect_wall_gaps(shared_wall)
625
+
626
+ # Determine door position
627
+ if gaps:
628
+ # Place door at first gap
629
+ gap_start, gap_end = gaps[0]
630
+ door_position = (gap_start + gap_end) / 2
631
+ logger.debug(f"Placing door at gap location: {door_position}")
632
+ else:
633
+ # Fallback: place door at midpoint of shared wall
634
+ door_position = np.mean(shared_wall, axis=0)
635
+ logger.debug(f"No gaps found, placing door at wall midpoint: {door_position}")
636
+
637
+ # Get wall direction
638
+ wall_direction = self.spatial_analyzer.get_wall_direction(shared_wall)
639
+
640
+ # Create door polygon
641
+ door = create_door_polygon(
642
+ position=tuple(door_position),
643
+ wall_direction=wall_direction,
644
+ door_width=self.config.door_width
645
+ )
646
+
647
+ doors.append(door)
648
+
649
+ except Exception as e:
650
+ logger.warning(f"Failed to place door between rooms: {e}")
651
+ continue
652
+
653
+ logger.info(f"Successfully detected {len(doors)} doors")
654
+ return doors
655
+
656
+
657
+ def create_door_polygon(
658
+ position: Tuple[float, float],
659
+ wall_direction: float,
660
+ door_width: int
661
+ ) -> WallPolygon:
662
+ """
663
+ Create a door polygon at the specified position.
664
+
665
+ The door is oriented perpendicular to the wall direction.
666
+
667
+ Parameters
668
+ ----------
669
+ position : Tuple[float, float]
670
+ (x, y) center point for door
671
+ wall_direction : float
672
+ Wall orientation in degrees (0-180)
673
+ door_width : int
674
+ Door width in pixels
675
+
676
+ Returns
677
+ -------
678
+ WallPolygon
679
+ WallPolygon with class_id=3, class_name="Door"
680
+ """
681
+ # Door is perpendicular to wall
682
+ door_angle = (wall_direction + 90) % 180
683
+ door_angle_rad = np.radians(door_angle)
684
+
685
+ # Create door as a rectangle perpendicular to wall
686
+ half_width = door_width / 2
687
+ half_depth = door_width / 4 # Door depth is half the width
688
+
689
+ # Direction vectors
690
+ dir_along = np.array([np.cos(door_angle_rad), np.sin(door_angle_rad)])
691
+ dir_perp = np.array([-np.sin(door_angle_rad), np.cos(door_angle_rad)])
692
+
693
+ # Four corners of the door rectangle
694
+ center = np.array(position)
695
+ p1 = center - half_width * dir_along - half_depth * dir_perp
696
+ p2 = center + half_width * dir_along - half_depth * dir_perp
697
+ p3 = center + half_width * dir_along + half_depth * dir_perp
698
+ p4 = center - half_width * dir_along + half_depth * dir_perp
699
+
700
+ # Convert to integer coordinates
701
+ points = [
702
+ (int(p1[0]), int(p1[1])),
703
+ (int(p2[0]), int(p2[1])),
704
+ (int(p3[0]), int(p3[1])),
705
+ (int(p4[0]), int(p4[1]))
706
+ ]
707
+
708
+ # Compute area and bbox
709
+ points_array = np.array(points)
710
+ area = float(cv2.contourArea(points_array))
711
+ x_coords = points_array[:, 0]
712
+ y_coords = points_array[:, 1]
713
+ bbox = (
714
+ int(x_coords.min()),
715
+ int(y_coords.min()),
716
+ int(x_coords.max() - x_coords.min()),
717
+ int(y_coords.max() - y_coords.min())
718
+ )
719
+
720
+ return WallPolygon(
721
+ class_id=3,
722
+ class_name="Door",
723
+ points=points,
724
+ area=area,
725
+ bbox=bbox,
726
+ confidence=1.0
727
+ )
728
+
729
+
730
+ class EdgeAnalyzer:
731
+ """Performs edge detection on floor plan images."""
732
+
733
+ def __init__(self, config: RefinementConfig):
734
+ """
735
+ Initialize edge analyzer.
736
+
737
+ Parameters
738
+ ----------
739
+ config : RefinementConfig
740
+ Configuration for edge detection parameters
741
+ """
742
+ self.config = config
743
+
744
+ def detect_edges(
745
+ self,
746
+ image: np.ndarray
747
+ ) -> np.ndarray:
748
+ """
749
+ Apply Canny edge detection to identify edges.
750
+
751
+ Uses config.canny_low_threshold and config.canny_high_threshold.
752
+
753
+ Parameters
754
+ ----------
755
+ image : np.ndarray
756
+ Input image (grayscale)
757
+
758
+ Returns
759
+ -------
760
+ np.ndarray
761
+ Binary edge map (same size as input)
762
+ """
763
+ if image is None or image.size == 0:
764
+ logger.error("Invalid image provided to edge detection")
765
+ return np.zeros((100, 100), dtype=np.uint8)
766
+
767
+ try:
768
+ # Ensure image is grayscale
769
+ if len(image.shape) == 3:
770
+ image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
771
+
772
+ # Apply Canny edge detection
773
+ edges = cv2.Canny(
774
+ image,
775
+ self.config.canny_low_threshold,
776
+ self.config.canny_high_threshold
777
+ )
778
+
779
+ logger.debug(f"Edge detection complete: {np.count_nonzero(edges)} edge pixels")
780
+ return edges
781
+
782
+ except cv2.error as e:
783
+ logger.error(f"OpenCV edge detection failed: {e}")
784
+ return np.zeros_like(image, dtype=np.uint8)
785
+ except Exception as e:
786
+ logger.error(f"Edge detection failed: {e}")
787
+ return np.zeros_like(image, dtype=np.uint8)
788
+
789
+
790
+ class LineDetector:
791
+ """Detects and analyzes line segments in images."""
792
+
793
+ def __init__(self, config: RefinementConfig):
794
+ """
795
+ Initialize line detector.
796
+
797
+ Parameters
798
+ ----------
799
+ config : RefinementConfig
800
+ Configuration for line detection parameters
801
+ """
802
+ self.config = config
803
+
804
+ def detect_lines(
805
+ self,
806
+ edge_map: np.ndarray
807
+ ) -> List[LineSegment]:
808
+ """
809
+ Extract line segments using HoughLinesP.
810
+
811
+ Uses config.hough_* parameters.
812
+
813
+ Parameters
814
+ ----------
815
+ edge_map : np.ndarray
816
+ Binary edge map from edge detection
817
+
818
+ Returns
819
+ -------
820
+ List[LineSegment]
821
+ List of ((x1, y1), (x2, y2)) line segments
822
+ """
823
+ if edge_map is None or edge_map.size == 0:
824
+ logger.warning("Invalid edge map provided to line detection")
825
+ return []
826
+
827
+ try:
828
+ # Apply HoughLinesP
829
+ lines = cv2.HoughLinesP(
830
+ edge_map,
831
+ rho=1,
832
+ theta=np.pi / 180,
833
+ threshold=self.config.hough_threshold,
834
+ minLineLength=self.config.hough_min_line_length,
835
+ maxLineGap=self.config.hough_max_line_gap
836
+ )
837
+
838
+ if lines is None:
839
+ logger.debug("No lines detected by HoughLinesP")
840
+ return []
841
+
842
+ # Convert to list of line segments
843
+ line_segments = []
844
+ for line in lines:
845
+ x1, y1, x2, y2 = line[0]
846
+ line_segments.append(((int(x1), int(y1)), (int(x2), int(y2))))
847
+
848
+ logger.debug(f"Detected {len(line_segments)} line segments")
849
+ return line_segments
850
+
851
+ except cv2.error as e:
852
+ logger.error(f"OpenCV line detection failed: {e}")
853
+ return []
854
+ except Exception as e:
855
+ logger.error(f"Line detection failed: {e}")
856
+ return []
857
+
858
+ def compute_line_angle(
859
+ self,
860
+ line: LineSegment
861
+ ) -> float:
862
+ """
863
+ Compute orientation angle of a line segment.
864
+
865
+ Parameters
866
+ ----------
867
+ line : LineSegment
868
+ Line segment ((x1, y1), (x2, y2))
869
+
870
+ Returns
871
+ -------
872
+ float
873
+ Angle in degrees (0-180 range)
874
+ """
875
+ (x1, y1), (x2, y2) = line
876
+ dx = x2 - x1
877
+ dy = y2 - y1
878
+
879
+ angle = np.arctan2(dy, dx)
880
+ angle_deg = np.degrees(angle) % 180
881
+
882
+ return float(angle_deg)
883
+
884
+ def compute_line_distance(
885
+ self,
886
+ line_a: LineSegment,
887
+ line_b: LineSegment
888
+ ) -> float:
889
+ """
890
+ Compute perpendicular distance between two parallel lines.
891
+
892
+ Parameters
893
+ ----------
894
+ line_a : LineSegment
895
+ First line segment
896
+ line_b : LineSegment
897
+ Second line segment
898
+
899
+ Returns
900
+ -------
901
+ float
902
+ Distance in pixels
903
+ """
904
+ # Use midpoints of lines
905
+ (x1_a, y1_a), (x2_a, y2_a) = line_a
906
+ (x1_b, y1_b), (x2_b, y2_b) = line_b
907
+
908
+ mid_a = np.array([(x1_a + x2_a) / 2, (y1_a + y2_a) / 2])
909
+ mid_b = np.array([(x1_b + x2_b) / 2, (y1_b + y2_b) / 2])
910
+
911
+ # Compute distance between midpoints
912
+ distance = np.linalg.norm(mid_b - mid_a)
913
+
914
+ return float(distance)
915
+
916
+ def find_parallel_pairs(
917
+ self,
918
+ lines: List[LineSegment]
919
+ ) -> List[Tuple[int, int]]:
920
+ """
921
+ Identify pairs of parallel lines with close proximity.
922
+
923
+ Algorithm:
924
+ 1. Compute orientation angle for each line
925
+ 2. For each line pair:
926
+ - Check if angles are within parallel_angle_tolerance
927
+ - Check if distance between lines < parallel_line_proximity
928
+ 3. Return indices of parallel pairs
929
+
930
+ Parameters
931
+ ----------
932
+ lines : List[LineSegment]
933
+ List of line segments
934
+
935
+ Returns
936
+ -------
937
+ List[Tuple[int, int]]
938
+ List of (line_idx_a, line_idx_b) tuples
939
+ """
940
+ if not lines or len(lines) < 2:
941
+ return []
942
+
943
+ pairs = []
944
+ n = len(lines)
945
+
946
+ # Compute angles for all lines
947
+ angles = [self.compute_line_angle(line) for line in lines]
948
+
949
+ for i in range(n):
950
+ for j in range(i + 1, n):
951
+ angle_i = angles[i]
952
+ angle_j = angles[j]
953
+
954
+ # Check angle similarity (handle wraparound at 180 degrees)
955
+ angle_diff = abs(angle_i - angle_j)
956
+ angle_diff = min(angle_diff, 180 - angle_diff)
957
+
958
+ if angle_diff > self.config.parallel_angle_tolerance:
959
+ continue
960
+
961
+ # Check distance
962
+ distance = self.compute_line_distance(lines[i], lines[j])
963
+
964
+ if distance > self.config.parallel_line_proximity:
965
+ continue
966
+
967
+ # Check minimum line length
968
+ (x1_i, y1_i), (x2_i, y2_i) = lines[i]
969
+ (x1_j, y1_j), (x2_j, y2_j) = lines[j]
970
+
971
+ len_i = np.sqrt((x2_i - x1_i)**2 + (y2_i - y1_i)**2)
972
+ len_j = np.sqrt((x2_j - x1_j)**2 + (y2_j - y1_j)**2)
973
+
974
+ if len_i < self.config.window_min_length or len_j < self.config.window_min_length:
975
+ continue
976
+
977
+ pairs.append((i, j))
978
+ logger.debug(
979
+ f"Found parallel pair: lines {i} and {j}, "
980
+ f"angle_diff={angle_diff:.1f}°, distance={distance:.1f}px"
981
+ )
982
+
983
+ logger.debug(f"Found {len(pairs)} parallel line pairs")
984
+ return pairs
985
+
986
+
987
+ class WindowDetector:
988
+ """Detects windows using edge detection and line analysis."""
989
+
990
+ def __init__(self, config: RefinementConfig):
991
+ self.config = config
992
+ self.edge_analyzer = EdgeAnalyzer(config)
993
+ self.line_detector = LineDetector(config)
994
+
995
+ def detect(
996
+ self,
997
+ image: np.ndarray,
998
+ wall_polygons: List[WallPolygon]
999
+ ) -> List[WallPolygon]:
1000
+ """
1001
+ Detect window locations using edge detection and line analysis.
1002
+
1003
+ Parameters
1004
+ ----------
1005
+ image : np.ndarray
1006
+ Preprocessed floor plan image (grayscale)
1007
+ wall_polygons : List[WallPolygon]
1008
+ List of vectorized wall polygons
1009
+
1010
+ Returns
1011
+ -------
1012
+ List[WallPolygon]
1013
+ List of window polygons with class_id=2, class_name="Window"
1014
+ """
1015
+ # Input validation
1016
+ if image is None or image.size == 0:
1017
+ logger.warning("Invalid image provided, skipping window detection")
1018
+ return []
1019
+
1020
+ if not wall_polygons:
1021
+ logger.warning("No wall polygons provided, skipping window detection")
1022
+ return []
1023
+
1024
+ logger.info(f"Detecting windows from image and {len(wall_polygons)} walls")
1025
+
1026
+ # Step 1: Edge detection
1027
+ edges = self.edge_analyzer.detect_edges(image)
1028
+
1029
+ # Step 2: Line detection
1030
+ lines = self.line_detector.detect_lines(edges)
1031
+
1032
+ if not lines:
1033
+ logger.info("No lines detected, no windows to place")
1034
+ return []
1035
+
1036
+ # Step 3: Find parallel line pairs
1037
+ parallel_pairs = self.line_detector.find_parallel_pairs(lines)
1038
+
1039
+ if not parallel_pairs:
1040
+ logger.info("No parallel line pairs found, no windows to place")
1041
+ return []
1042
+
1043
+ logger.info(f"Found {len(parallel_pairs)} parallel line pairs")
1044
+
1045
+ windows = []
1046
+
1047
+ # Step 4: Create window polygons from parallel pairs
1048
+ for idx_a, idx_b in parallel_pairs:
1049
+ try:
1050
+ line_a = lines[idx_a]
1051
+ line_b = lines[idx_b]
1052
+
1053
+ # Compute window center
1054
+ (x1_a, y1_a), (x2_a, y2_a) = line_a
1055
+ (x1_b, y1_b), (x2_b, y2_b) = line_b
1056
+
1057
+ mid_a = np.array([(x1_a + x2_a) / 2, (y1_a + y2_a) / 2])
1058
+ mid_b = np.array([(x1_b + x2_b) / 2, (y1_b + y2_b) / 2])
1059
+ window_center = (mid_a + mid_b) / 2
1060
+
1061
+ # Find which wall this window belongs to
1062
+ wall = self._find_containing_wall(tuple(window_center), wall_polygons)
1063
+
1064
+ if wall is None:
1065
+ logger.debug(f"Window at {window_center} not on any wall, skipping")
1066
+ continue
1067
+
1068
+ # Verify alignment with wall direction
1069
+ wall_points = np.array(wall.points, dtype=np.float32)
1070
+ spatial_analyzer = SpatialAnalyzer(self.config)
1071
+ wall_direction = spatial_analyzer.get_wall_direction(wall_points)
1072
+
1073
+ line_direction = self.line_detector.compute_line_angle(line_a)
1074
+
1075
+ # Check if line is parallel to wall (within tolerance)
1076
+ angle_diff = abs(wall_direction - line_direction)
1077
+ angle_diff = min(angle_diff, 180 - angle_diff)
1078
+
1079
+ if angle_diff > self.config.parallel_angle_tolerance:
1080
+ logger.debug(
1081
+ f"Window lines not aligned with wall "
1082
+ f"(wall={wall_direction:.1f}°, line={line_direction:.1f}°), skipping"
1083
+ )
1084
+ continue
1085
+
1086
+ # Create window polygon
1087
+ window = create_window_polygon(line_a, line_b)
1088
+ windows.append(window)
1089
+
1090
+ except Exception as e:
1091
+ logger.warning(f"Failed to create window from parallel lines: {e}")
1092
+ continue
1093
+
1094
+ logger.info(f"Successfully detected {len(windows)} windows")
1095
+ return windows
1096
+
1097
+ def _find_containing_wall(
1098
+ self,
1099
+ point: Tuple[float, float],
1100
+ wall_polygons: List[WallPolygon]
1101
+ ) -> Optional[WallPolygon]:
1102
+ """
1103
+ Find which wall contains the given point.
1104
+
1105
+ Parameters
1106
+ ----------
1107
+ point : Tuple[float, float]
1108
+ Point coordinates (x, y)
1109
+ wall_polygons : List[WallPolygon]
1110
+ List of wall polygons
1111
+
1112
+ Returns
1113
+ -------
1114
+ WallPolygon or None
1115
+ Wall containing the point, or None if not found
1116
+ """
1117
+ spatial_analyzer = SpatialAnalyzer(self.config)
1118
+
1119
+ for wall in wall_polygons:
1120
+ if not wall.is_wall:
1121
+ continue
1122
+
1123
+ if spatial_analyzer.is_point_on_wall(point, wall):
1124
+ return wall
1125
+
1126
+ return None
1127
+
1128
+
1129
+ def create_window_polygon(
1130
+ line_a: LineSegment,
1131
+ line_b: LineSegment
1132
+ ) -> WallPolygon:
1133
+ """
1134
+ Create a window polygon from a pair of parallel lines.
1135
+
1136
+ Parameters
1137
+ ----------
1138
+ line_a : LineSegment
1139
+ First line segment ((x1, y1), (x2, y2))
1140
+ line_b : LineSegment
1141
+ Second line segment ((x1, y1), (x2, y2))
1142
+
1143
+ Returns
1144
+ -------
1145
+ WallPolygon
1146
+ WallPolygon with class_id=2, class_name="Window"
1147
+ """
1148
+ (x1_a, y1_a), (x2_a, y2_a) = line_a
1149
+ (x1_b, y1_b), (x2_b, y2_b) = line_b
1150
+
1151
+ # Create rectangle from the two parallel lines
1152
+ # Use the endpoints of both lines as corners
1153
+ points = [
1154
+ (int(x1_a), int(y1_a)),
1155
+ (int(x2_a), int(y2_a)),
1156
+ (int(x2_b), int(y2_b)),
1157
+ (int(x1_b), int(y1_b))
1158
+ ]
1159
+
1160
+ # Compute area and bbox
1161
+ points_array = np.array(points)
1162
+ area = float(cv2.contourArea(points_array))
1163
+
1164
+ # Handle degenerate case where contour area is 0
1165
+ if area == 0:
1166
+ area = 1.0
1167
+
1168
+ x_coords = points_array[:, 0]
1169
+ y_coords = points_array[:, 1]
1170
+ bbox = (
1171
+ int(x_coords.min()),
1172
+ int(y_coords.min()),
1173
+ int(x_coords.max() - x_coords.min()),
1174
+ int(y_coords.max() - y_coords.min())
1175
+ )
1176
+
1177
+ return WallPolygon(
1178
+ class_id=2,
1179
+ class_name="Window",
1180
+ points=points,
1181
+ area=area,
1182
+ bbox=bbox,
1183
+ confidence=1.0
1184
+ )
src/geometry/__init__.py ADDED
File without changes
src/geometry/pipeline.py ADDED
@@ -0,0 +1,372 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ pipeline.py
3
+ -----------
4
+ Orchestrates the complete Phase 3 geometry reconstruction pipeline:
5
+
6
+ segmentation_result → vectorize → estimate_scale → build_graph → save
7
+
8
+ Usage:
9
+ from src.geometry.pipeline import GeometryPipeline
10
+
11
+ pipeline = GeometryPipeline()
12
+ result = pipeline.run(segmentation_result, image)
13
+ result.save("outputs/geometry/")
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Optional
22
+
23
+ import cv2
24
+ import numpy as np
25
+
26
+ from .wall_vectorizer import WallVectorizer, VectorizationResult
27
+ from .scale_estimator import ScaleEstimator, ScaleEstimate
28
+ from .room_graph import RoomGraphBuilder, FloorPlanGraph
29
+
30
+ # Import refinement module (optional dependency)
31
+ try:
32
+ from src.detection.refinement import RefinementConfig, refine_detections
33
+ REFINEMENT_AVAILABLE = True
34
+ except ImportError:
35
+ REFINEMENT_AVAILABLE = False
36
+ RefinementConfig = None
37
+
38
+
39
+ # ── Config ────────────────────────────────────────────────────────────────────
40
+
41
+ @dataclass
42
+ class GeometryConfig:
43
+ """Tunable parameters for Phase 3 geometry pipeline."""
44
+ epsilon_factor: float = 0.008 # Polygon simplification
45
+ min_area: int = 200 # Min polygon area (px²)
46
+ morph_kernel: int = 3 # Morphological cleanup kernel
47
+ simplify_walls: bool = True # Extra wall simplification
48
+ proximity_threshold: int = 200 # Room adjacency threshold (px)
49
+ door_proximity: int = 150 # Door-to-room proximity (px)
50
+ target_image_size: int = 1024 # Phase 1 resize target
51
+
52
+ # Detection refinement parameters
53
+ use_refinement: bool = True # Enable detection refinement
54
+ refinement_config: Optional['RefinementConfig'] = None # Custom refinement config
55
+
56
+ # Visualization parameters
57
+ debug_visualization: bool = False # Enable debug visualizations
58
+ visualization_dir: str = "outputs/debug_viz" # Directory for debug images
59
+
60
+ def __post_init__(self):
61
+ """Initialize default refinement config if not provided."""
62
+ if self.use_refinement and self.refinement_config is None and REFINEMENT_AVAILABLE:
63
+ from src.detection.refinement import RefinementConfig
64
+ self.refinement_config = RefinementConfig()
65
+
66
+
67
+ # ── Result ────────────────────────────────────────────────────────────────────
68
+
69
+ @dataclass
70
+ class GeometryResult:
71
+ """Complete Phase 3 output for one floor plan."""
72
+ source_path: str
73
+ vectorization: VectorizationResult
74
+ scale: ScaleEstimate
75
+ graph: FloorPlanGraph
76
+ config: GeometryConfig = field(repr=False)
77
+ segmentation_result: object = field(default=None, repr=False) # raw SegmentationResult
78
+
79
+ def save(self, output_dir: str, prefix: str = "") -> dict[str, str]:
80
+ """
81
+ Save all Phase 3 outputs.
82
+
83
+ Returns:
84
+ Dict mapping output type → file path.
85
+ """
86
+ out = Path(output_dir)
87
+ out.mkdir(parents=True, exist_ok=True)
88
+
89
+ stem = Path(self.source_path).stem
90
+ p = f"{prefix}{stem}" if prefix else stem
91
+
92
+ paths = {
93
+ "graph_json": str(out / f"{p}_graph.json"),
94
+ "scale_json": str(out / f"{p}_scale.json"),
95
+ }
96
+
97
+ # Save room graph as JSON
98
+ graph_data = self.graph.to_dict()
99
+ with open(paths["graph_json"], "w") as f:
100
+ json.dump(graph_data, f, indent=2)
101
+
102
+ # Save scale estimate
103
+ scale_data = {
104
+ "pixels_per_metre": self.scale.pixels_per_metre,
105
+ "metres_per_pixel": self.scale.metres_per_pixel,
106
+ "confidence": self.scale.confidence,
107
+ "method": self.scale.method,
108
+ "notes": self.scale.notes,
109
+ }
110
+ with open(paths["scale_json"], "w") as f:
111
+ json.dump(scale_data, f, indent=2)
112
+
113
+ # Print summary
114
+ print(f"\nGeometry reconstruction complete: {self.source_path}")
115
+ print(f" Scale: {self.scale.pixels_per_metre:.1f} px/m "
116
+ f"(method: {self.scale.method}, "
117
+ f"confidence: {self.scale.confidence:.0%})")
118
+ print(f" Rooms: {len(self.graph.nodes)}")
119
+ print(f" Connections: {len(self.graph.edges)}")
120
+ print(f" Walls: {len(self.vectorization.walls)} polygons")
121
+ print(f" Doors: {len(self.vectorization.doors)} polygons")
122
+ print(f" Files saved to: {output_dir}/")
123
+
124
+ return paths
125
+
126
+
127
+ # ── Pipeline ────��─────────────────────────────────────────────────────────────
128
+
129
+ class GeometryPipeline:
130
+ """
131
+ Phase 3: Geometry reconstruction pipeline.
132
+
133
+ Takes Phase 2 segmentation output and produces:
134
+ - Vectorized wall/room/door/window polygons
135
+ - Pixel-to-metre scale estimate
136
+ - Room connectivity graph
137
+
138
+ This output feeds directly into Phase 4 (3D extrusion).
139
+
140
+ Example:
141
+ from src.segmentation.predictor import FloorPlanPredictor
142
+ from src.geometry.pipeline import GeometryPipeline
143
+
144
+ predictor = FloorPlanPredictor("models/segmentation/best.pt")
145
+ seg_result = predictor.predict("outputs/plan_4_cleaned.png")
146
+
147
+ geo_pipeline = GeometryPipeline()
148
+ geo_result = geo_pipeline.run(seg_result, image)
149
+ geo_result.save("outputs/geometry/")
150
+ """
151
+
152
+ def __init__(self, config: Optional[GeometryConfig] = None):
153
+ self.config = config or GeometryConfig()
154
+ cfg = self.config
155
+
156
+ self.vectorizer = WallVectorizer(
157
+ epsilon_factor=cfg.epsilon_factor,
158
+ min_area=cfg.min_area,
159
+ morph_kernel=cfg.morph_kernel,
160
+ simplify_walls=cfg.simplify_walls,
161
+ )
162
+ self.scale_estimator = ScaleEstimator(
163
+ target_image_size=cfg.target_image_size,
164
+ )
165
+ self.graph_builder = RoomGraphBuilder(
166
+ proximity_threshold=cfg.proximity_threshold,
167
+ door_proximity=cfg.door_proximity,
168
+ )
169
+
170
+ def run(
171
+ self,
172
+ segmentation_result,
173
+ image: np.ndarray,
174
+ image_path: str = "unknown",
175
+ ) -> GeometryResult:
176
+ """
177
+ Run the full Phase 3 pipeline.
178
+
179
+ Args:
180
+ segmentation_result: FloorPlanPredictor output (Phase 2).
181
+ image: The original floor plan image (numpy array).
182
+ image_path: Source path for naming output files.
183
+
184
+ Returns:
185
+ GeometryResult with all Phase 3 outputs.
186
+ """
187
+ # ── Step 1: Vectorize masks → polygons ────────────────────────────
188
+ print("[1/3] Vectorizing segmentation masks...")
189
+ vec_result = self.vectorizer.extract(
190
+ segmentation_result,
191
+ image_shape=image.shape[:2],
192
+ )
193
+ print(f" Walls: {len(vec_result.walls)}")
194
+ print(f" Rooms: {len(vec_result.rooms)}")
195
+ print(f" Doors: {len(vec_result.doors)}")
196
+ print(f" Windows: {len(vec_result.windows)}")
197
+
198
+ # Store original for comparison if debug visualization enabled
199
+ vec_result_before = None
200
+ if self.config.debug_visualization:
201
+ vec_result_before = vec_result
202
+
203
+ # ── NEW: Apply detection refinement if enabled ────────────────────
204
+ if self.config.use_refinement and REFINEMENT_AVAILABLE:
205
+ print("[1.5/3] Applying detection refinement...")
206
+ try:
207
+ vec_result = refine_detections(
208
+ yolo_output=segmentation_result,
209
+ image=image,
210
+ geometry_output=vec_result,
211
+ config=self.config.refinement_config
212
+ )
213
+ print(f" Refined Doors: {len(vec_result.doors)}")
214
+ print(f" Refined Windows: {len(vec_result.windows)}")
215
+
216
+ # Debug visualization if enabled
217
+ if self.config.debug_visualization:
218
+ self._create_debug_visualizations(
219
+ image, vec_result_before, vec_result, image_path
220
+ )
221
+
222
+ except Exception as e:
223
+ print(f" WARNING: Refinement failed: {e}")
224
+ print(f" Continuing with original YOLO detections")
225
+ elif self.config.use_refinement and not REFINEMENT_AVAILABLE:
226
+ print(" WARNING: Refinement requested but module not available")
227
+
228
+ # ── Step 2: Estimate scale ─────────────────────────────────────────
229
+ print("[2/3] Estimating scale...")
230
+ scale = self.scale_estimator.estimate(image, vec_result)
231
+ print(f" {scale.pixels_per_metre:.1f} px/m "
232
+ f"(method: {scale.method}, "
233
+ f"confidence: {scale.confidence:.0%})")
234
+
235
+ # ── Step 3: Build room graph ───────────────────────────────────────
236
+ print("[3/3] Building room connectivity graph...")
237
+ graph = self.graph_builder.build(vec_result, scale)
238
+ print(f" {len(graph.nodes)} rooms, {len(graph.edges)} connections")
239
+ for node in graph.nodes:
240
+ print(f" → {node.class_name}: {node.area_m2:.1f} m²")
241
+
242
+ return GeometryResult(
243
+ source_path=image_path,
244
+ vectorization=vec_result,
245
+ scale=scale,
246
+ graph=graph,
247
+ config=self.config,
248
+ segmentation_result=segmentation_result,
249
+ )
250
+
251
+ def _create_debug_visualizations(
252
+ self,
253
+ image: np.ndarray,
254
+ vec_before: VectorizationResult,
255
+ vec_after: VectorizationResult,
256
+ image_path: str
257
+ ):
258
+ """
259
+ Create debug visualizations for detection refinement.
260
+
261
+ Args:
262
+ image: Original image
263
+ vec_before: VectorizationResult before refinement
264
+ vec_after: VectorizationResult after refinement
265
+ image_path: Source image path for naming
266
+ """
267
+ try:
268
+ from src.utils.visualization import (
269
+ visualize_vectorization_result,
270
+ visualize_comparison
271
+ )
272
+ from pathlib import Path
273
+
274
+ # Create output directory
275
+ out_dir = Path(self.config.visualization_dir)
276
+ out_dir.mkdir(parents=True, exist_ok=True)
277
+
278
+ # Get filename stem
279
+ stem = Path(image_path).stem
280
+
281
+ # Create before visualization
282
+ before_path = str(out_dir / f"{stem}_before_refinement.png")
283
+ visualize_vectorization_result(image, vec_before, before_path, show_labels=False)
284
+
285
+ # Create after visualization
286
+ after_path = str(out_dir / f"{stem}_after_refinement.png")
287
+ visualize_vectorization_result(image, vec_after, after_path, show_labels=True)
288
+
289
+ # Create comparison
290
+ comparison_path = str(out_dir / f"{stem}_comparison.png")
291
+ detections_before = {
292
+ "walls": [np.array(w.points) for w in vec_before.walls],
293
+ "rooms": [np.array(r.points) for r in vec_before.rooms],
294
+ "doors": [np.array(d.points) for d in vec_before.doors],
295
+ "windows": [np.array(w.points) for w in vec_before.windows]
296
+ }
297
+ detections_after = {
298
+ "walls": [np.array(w.points) for w in vec_after.walls],
299
+ "rooms": [np.array(r.points) for r in vec_after.rooms],
300
+ "doors": [np.array(d.points) for d in vec_after.doors],
301
+ "windows": [np.array(w.points) for w in vec_after.windows]
302
+ }
303
+
304
+ from src.utils.visualization import visualize_comparison
305
+ visualize_comparison(image, detections_before, detections_after, comparison_path)
306
+
307
+ print(f" Debug visualizations saved to: {out_dir}/")
308
+
309
+ except Exception as e:
310
+ print(f" WARNING: Failed to create debug visualizations: {e}")
311
+
312
+ def run_and_visualize(
313
+ self,
314
+ segmentation_result,
315
+ image: np.ndarray,
316
+ image_path: str = "unknown",
317
+ output_dir: str = "outputs/geometry",
318
+ ) -> tuple[GeometryResult, np.ndarray, np.ndarray]:
319
+ """
320
+ Run pipeline and generate visualization images.
321
+
322
+ Returns:
323
+ (GeometryResult, polygon_viz_image, graph_viz_image)
324
+ """
325
+ result = self.run(segmentation_result, image, image_path)
326
+
327
+ # Visualization 1: vectorized polygons
328
+ poly_viz = self.vectorizer.draw(image, result.vectorization)
329
+
330
+ # Visualization 2: room graph
331
+ graph_viz = self.graph_builder.draw(image, result.graph)
332
+
333
+ # Save visualizations
334
+ out = Path(output_dir)
335
+ out.mkdir(parents=True, exist_ok=True)
336
+ stem = Path(image_path).stem
337
+
338
+ cv2.imwrite(str(out / f"{stem}_polygons.png"), poly_viz)
339
+ cv2.imwrite(str(out / f"{stem}_graph.png"), graph_viz)
340
+
341
+ return result, poly_viz, graph_viz
342
+
343
+ def run_batch(
344
+ self,
345
+ items: list[dict],
346
+ output_dir: str,
347
+ ) -> list[GeometryResult]:
348
+ """
349
+ Run geometry pipeline on multiple floor plans.
350
+
351
+ Args:
352
+ items: List of dicts with keys 'segmentation_result',
353
+ 'image', 'image_path'.
354
+ output_dir: Where to save outputs.
355
+
356
+ Returns:
357
+ List of GeometryResult objects.
358
+ """
359
+ results = []
360
+ for i, item in enumerate(items, 1):
361
+ print(f"\n── [{i}/{len(items)}] {item.get('image_path', '?')} ──")
362
+ try:
363
+ result = self.run(
364
+ item["segmentation_result"],
365
+ item["image"],
366
+ item.get("image_path", f"item_{i}"),
367
+ )
368
+ result.save(output_dir)
369
+ results.append(result)
370
+ except Exception as e:
371
+ print(f" ERROR: {e}")
372
+ return results
src/geometry/room_graph.py ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ room_graph.py
3
+ -------------
4
+ Builds a room connectivity graph from vectorized floor plan polygons.
5
+
6
+ The graph encodes:
7
+ - Nodes: rooms (with metadata — type, area, centroid)
8
+ - Edges: connections between rooms via doors or shared walls
9
+
10
+ This structured representation is the bridge between 2D geometry
11
+ and the 3D reconstruction in Phase 4.
12
+
13
+ Usage:
14
+ from src.geometry.room_graph import RoomGraphBuilder
15
+
16
+ builder = RoomGraphBuilder()
17
+ graph = builder.build(vectorization_result, scale_estimate)
18
+ print(graph.summary)
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass, field
24
+ from typing import Optional
25
+ import math
26
+ import numpy as np
27
+ import cv2
28
+
29
+
30
+ # ── Data structures ───────────────────────────────────────────────────────────
31
+
32
+ @dataclass
33
+ class RoomNode:
34
+ """A single room in the floor plan graph."""
35
+ node_id: int
36
+ class_name: str
37
+ class_id: int
38
+ centroid: tuple[float, float] # (x, y) in pixels
39
+ area_px: float
40
+ area_m2: float # real-world area
41
+ bbox: tuple[int, int, int, int] # (x, y, w, h) pixels
42
+ polygon: list[tuple[int, int]] # pixel boundary points
43
+
44
+ @property
45
+ def label(self) -> str:
46
+ return f"{self.class_name} ({self.area_m2:.1f}m²)"
47
+
48
+
49
+ @dataclass
50
+ class RoomEdge:
51
+ """A connection between two rooms."""
52
+ node_a: int # node_id of room A
53
+ node_b: int # node_id of room B
54
+ edge_type: str # 'door', 'opening', 'shared_wall'
55
+ via_element: Optional[int] = None # index of door/window polygon if any
56
+ distance_px: float = 0.0
57
+
58
+
59
+ @dataclass
60
+ class FloorPlanGraph:
61
+ """Complete room connectivity graph for one floor plan."""
62
+ nodes: list[RoomNode] = field(default_factory=list)
63
+ edges: list[RoomEdge] = field(default_factory=list)
64
+ scale_method: str = "unknown"
65
+
66
+ @property
67
+ def summary(self) -> dict:
68
+ return {
69
+ "rooms": len(self.nodes),
70
+ "connections": len(self.edges),
71
+ "room_types": [n.class_name for n in self.nodes],
72
+ "total_area_m2": round(sum(n.area_m2 for n in self.nodes), 2),
73
+ "scale_method": self.scale_method,
74
+ }
75
+
76
+ def get_node(self, node_id: int) -> Optional[RoomNode]:
77
+ for node in self.nodes:
78
+ if node.node_id == node_id:
79
+ return node
80
+ return None
81
+
82
+ def get_neighbours(self, node_id: int) -> list[RoomNode]:
83
+ neighbour_ids = set()
84
+ for edge in self.edges:
85
+ if edge.node_a == node_id:
86
+ neighbour_ids.add(edge.node_b)
87
+ elif edge.node_b == node_id:
88
+ neighbour_ids.add(edge.node_a)
89
+ return [self.get_node(nid) for nid in neighbour_ids if self.get_node(nid)]
90
+
91
+ def to_dict(self) -> dict:
92
+ """Serialisable representation for saving/loading."""
93
+ return {
94
+ "nodes": [
95
+ {
96
+ "id": n.node_id,
97
+ "class_name": n.class_name,
98
+ "class_id": n.class_id,
99
+ "centroid": list(n.centroid),
100
+ "area_px": n.area_px,
101
+ "area_m2": n.area_m2,
102
+ "bbox": list(n.bbox),
103
+ "polygon": n.polygon,
104
+ }
105
+ for n in self.nodes
106
+ ],
107
+ "edges": [
108
+ {
109
+ "node_a": e.node_a,
110
+ "node_b": e.node_b,
111
+ "edge_type": e.edge_type,
112
+ "distance": e.distance_px,
113
+ }
114
+ for e in self.edges
115
+ ],
116
+ "summary": self.summary,
117
+ }
118
+
119
+
120
+ # ── Graph builder ─────────────────────────────────────────────────────────────
121
+
122
+ class RoomGraphBuilder:
123
+ """
124
+ Builds a room connectivity graph from Phase 3 vectorization output.
125
+
126
+ Connection strategy:
127
+ 1. Door proximity — if a door polygon centroid is between two room
128
+ centroids, connect those rooms with a 'door' edge.
129
+ 2. Centroid proximity — rooms whose centroids are within
130
+ proximity_threshold pixels are connected with a 'shared_wall' edge.
131
+
132
+ Args:
133
+ proximity_threshold: Max distance between room centroids to infer
134
+ adjacency (pixels). Scaled with image size.
135
+ door_proximity: Max distance from a door to a room centroid
136
+ to consider the door as connecting that room.
137
+ """
138
+
139
+ def __init__(
140
+ self,
141
+ proximity_threshold: int = 200,
142
+ door_proximity: int = 150,
143
+ ):
144
+ self.proximity_threshold = proximity_threshold
145
+ self.door_proximity = door_proximity
146
+
147
+ def build(
148
+ self,
149
+ vectorization_result,
150
+ scale_estimate=None,
151
+ ) -> FloorPlanGraph:
152
+ """
153
+ Build the room graph from a VectorizationResult.
154
+
155
+ Args:
156
+ vectorization_result: Output from WallVectorizer.extract()
157
+ scale_estimate: Output from ScaleEstimator.estimate()
158
+
159
+ Returns:
160
+ FloorPlanGraph with nodes and edges.
161
+ """
162
+ graph = FloorPlanGraph(
163
+ scale_method=scale_estimate.method if scale_estimate else "none"
164
+ )
165
+
166
+ # ── Step 1: Create room nodes ──────────────────────────────────────
167
+ for i, room in enumerate(vectorization_result.rooms):
168
+ area_m2 = 0.0
169
+ if scale_estimate:
170
+ area_m2 = scale_estimate.area_px_to_m2(room.area)
171
+
172
+ cx, cy = room.centroid
173
+ node = RoomNode(
174
+ node_id=i,
175
+ class_name=room.class_name,
176
+ class_id=room.class_id,
177
+ centroid=(cx, cy),
178
+ area_px=room.area,
179
+ area_m2=round(area_m2, 2),
180
+ bbox=room.bbox,
181
+ polygon=room.points,
182
+ )
183
+ graph.nodes.append(node)
184
+
185
+ if not graph.nodes:
186
+ return graph
187
+
188
+ # ── Step 2: Connect rooms via doors ───────────────────────────────
189
+ for door_idx, door in enumerate(vectorization_result.doors):
190
+ door_cx, door_cy = door.centroid
191
+ nearby_rooms = []
192
+
193
+ for node in graph.nodes:
194
+ dist = _euclidean(door_cx, door_cy, *node.centroid)
195
+ if dist <= self.door_proximity:
196
+ nearby_rooms.append((dist, node.node_id))
197
+
198
+ nearby_rooms.sort()
199
+ if len(nearby_rooms) >= 2:
200
+ _, id_a = nearby_rooms[0]
201
+ _, id_b = nearby_rooms[1]
202
+ if id_a != id_b and not _edge_exists(graph, id_a, id_b):
203
+ graph.edges.append(RoomEdge(
204
+ node_a=id_a,
205
+ node_b=id_b,
206
+ edge_type="door",
207
+ via_element=door_idx,
208
+ distance_px=nearby_rooms[1][0],
209
+ ))
210
+
211
+ # ── Step 3: Connect adjacent rooms by centroid proximity ──────────
212
+ n = len(graph.nodes)
213
+ for i in range(n):
214
+ for j in range(i + 1, n):
215
+ node_a = graph.nodes[i]
216
+ node_b = graph.nodes[j]
217
+ dist = _euclidean(*node_a.centroid, *node_b.centroid)
218
+
219
+ if dist <= self.proximity_threshold:
220
+ if not _edge_exists(graph, node_a.node_id, node_b.node_id):
221
+ graph.edges.append(RoomEdge(
222
+ node_a=node_a.node_id,
223
+ node_b=node_b.node_id,
224
+ edge_type="shared_wall",
225
+ distance_px=dist,
226
+ ))
227
+
228
+ return graph
229
+
230
+ def draw(
231
+ self,
232
+ image: np.ndarray,
233
+ graph: FloorPlanGraph,
234
+ ) -> np.ndarray:
235
+ """
236
+ Visualise the room graph overlaid on the floor plan image.
237
+
238
+ Nodes drawn as circles with room labels.
239
+ Edges drawn as lines between centroids, coloured by type.
240
+ """
241
+ if len(image.shape) == 2:
242
+ canvas = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
243
+ else:
244
+ canvas = image.copy()
245
+
246
+ edge_colors = {
247
+ "door": (50, 200, 200),
248
+ "shared_wall": (150, 150, 150),
249
+ "opening": (200, 180, 50),
250
+ }
251
+
252
+ # Draw edges first (behind nodes)
253
+ for edge in graph.edges:
254
+ node_a = graph.get_node(edge.node_a)
255
+ node_b = graph.get_node(edge.node_b)
256
+ if node_a and node_b:
257
+ color = edge_colors.get(edge.edge_type, (200, 200, 200))
258
+ pt_a = (int(node_a.centroid[0]), int(node_a.centroid[1]))
259
+ pt_b = (int(node_b.centroid[0]), int(node_b.centroid[1]))
260
+ cv2.line(canvas, pt_a, pt_b, color, 2)
261
+
262
+ # Label edge type at midpoint
263
+ mid = ((pt_a[0] + pt_b[0]) // 2, (pt_a[1] + pt_b[1]) // 2)
264
+ cv2.putText(canvas, edge.edge_type, mid,
265
+ cv2.FONT_HERSHEY_SIMPLEX, 0.3, color, 1)
266
+
267
+ # Draw nodes
268
+ for node in graph.nodes:
269
+ cx, cy = int(node.centroid[0]), int(node.centroid[1])
270
+ cv2.circle(canvas, (cx, cy), 12, (50, 50, 200), -1)
271
+ cv2.circle(canvas, (cx, cy), 12, (255, 255, 255), 2)
272
+ cv2.putText(
273
+ canvas,
274
+ f"{node.class_name}",
275
+ (cx - 30, cy - 16),
276
+ cv2.FONT_HERSHEY_SIMPLEX, 0.4, (255, 255, 255), 1,
277
+ )
278
+ cv2.putText(
279
+ canvas,
280
+ f"{node.area_m2:.1f}m²",
281
+ (cx - 20, cy + 26),
282
+ cv2.FONT_HERSHEY_SIMPLEX, 0.35, (200, 220, 200), 1,
283
+ )
284
+
285
+ return canvas
286
+
287
+
288
+ # ── Helpers ───────────────────────────────────────────────────────────────────
289
+
290
+ def _euclidean(x1, y1, x2, y2) -> float:
291
+ return math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)
292
+
293
+ def _edge_exists(graph: FloorPlanGraph, id_a: int, id_b: int) -> bool:
294
+ for edge in graph.edges:
295
+ if (edge.node_a == id_a and edge.node_b == id_b) or \
296
+ (edge.node_a == id_b and edge.node_b == id_a):
297
+ return True
298
+ return False
src/geometry/scale_estimator.py ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ scale_estimator.py
3
+ ------------------
4
+ Estimates the pixel-to-metre scale of a floor plan image.
5
+
6
+ Strategies (in priority order):
7
+ 1. OCR — detect dimension annotations in the image (e.g. "4.5m", "450cm")
8
+ 2. Standard room size — use detected room polygons and compare to known
9
+ average room dimensions to infer scale
10
+ 3. Fallback — assume a standard A4/A3 drawing at 1:100 scale
11
+
12
+ Usage:
13
+ from src.geometry.scale_estimator import ScaleEstimator
14
+
15
+ estimator = ScaleEstimator()
16
+ scale = estimator.estimate(image, vectorization_result)
17
+ print(f"1 pixel = {scale.pixels_per_metre:.2f} px/m")
18
+ real_area = polygon.area / (scale.pixels_per_metre ** 2)
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ from dataclasses import dataclass
25
+ from typing import Optional
26
+
27
+ import cv2
28
+ import numpy as np
29
+
30
+
31
+ # ── Data structures ───────────────────────────────────────────────────────────
32
+
33
+ @dataclass
34
+ class ScaleEstimate:
35
+ """Pixel-to-real-world scale estimate."""
36
+ pixels_per_metre: float # How many pixels = 1 metre
37
+ confidence: float # 0-1, how confident we are
38
+ method: str # 'ocr', 'room_size', 'fallback'
39
+ notes: str = ""
40
+
41
+ @property
42
+ def metres_per_pixel(self) -> float:
43
+ return 1.0 / self.pixels_per_metre
44
+
45
+ def pixels_to_metres(self, pixels: float) -> float:
46
+ return pixels / self.pixels_per_metre
47
+
48
+ def metres_to_pixels(self, metres: float) -> float:
49
+ return metres * self.pixels_per_metre
50
+
51
+ def area_px_to_m2(self, area_px: float) -> float:
52
+ return area_px / (self.pixels_per_metre ** 2)
53
+
54
+
55
+ # ── Known room size priors (metres²) ─────────────────────────────────────────
56
+
57
+ ROOM_SIZE_PRIORS = {
58
+ "Bedroom": (9.0, 20.0), # typical: 9–20 m²
59
+ "LivingRoom": (15.0, 40.0), # typical: 15–40 m²
60
+ "Kitchen": (7.0, 20.0), # typical: 7–20 m²
61
+ "Bathroom": (3.0, 8.0), # typical: 3–8 m²
62
+ "Corridor": (2.0, 12.0), # typical: 2–12 m²
63
+ "Balcony": (2.0, 10.0), # typical: 2–10 m²
64
+ }
65
+
66
+
67
+ # ── Scale estimator ───────────────────────────────────────────────────────────
68
+
69
+ class ScaleEstimator:
70
+ """
71
+ Estimates pixel-to-metre conversion factor for a floor plan.
72
+
73
+ Args:
74
+ target_image_size: The longer dimension of the normalized image (px).
75
+ Should match Phase 1 target_size (default 1024).
76
+ fallback_scale: Metres per pixel to use when all else fails.
77
+ Default assumes a 10m × 10m plan at 1024px = 10m.
78
+ """
79
+
80
+ def __init__(
81
+ self,
82
+ target_image_size: int = 1024,
83
+ fallback_scale: float = 0.015, # ~15mm per pixel = typical floor plan
84
+ ):
85
+ self.target_image_size = target_image_size
86
+ self.fallback_scale = fallback_scale
87
+
88
+ def estimate(
89
+ self,
90
+ image: np.ndarray,
91
+ vectorization_result=None,
92
+ ) -> ScaleEstimate:
93
+ """
94
+ Estimate scale using all available strategies.
95
+
96
+ Args:
97
+ image: Grayscale or BGR floor plan image.
98
+ vectorization_result: Optional VectorizationResult from Phase 3.
99
+
100
+ Returns:
101
+ ScaleEstimate with best available estimate.
102
+ """
103
+ # Strategy 1: OCR-based scale detection
104
+ ocr_result = self._estimate_from_ocr(image)
105
+ if ocr_result is not None:
106
+ return ocr_result
107
+
108
+ # Strategy 2: Room size heuristics
109
+ if vectorization_result is not None and vectorization_result.rooms:
110
+ room_result = self._estimate_from_rooms(vectorization_result)
111
+ if room_result is not None:
112
+ return room_result
113
+
114
+ # Strategy 3: Fallback
115
+ return self._fallback_estimate(image)
116
+
117
+ def pixels_to_metres(self, pixels: float, scale: ScaleEstimate) -> float:
118
+ return scale.pixels_to_metres(pixels)
119
+
120
+ # ── Strategies ────────────────────────────────────────────────────────────
121
+
122
+ def _estimate_from_ocr(
123
+ self, image: np.ndarray
124
+ ) -> Optional[ScaleEstimate]:
125
+ """
126
+ Try to detect dimension text (e.g. '4.5m', '3500mm', '450cm')
127
+ in the image using pytesseract OCR.
128
+ """
129
+ try:
130
+ import pytesseract
131
+ except ImportError:
132
+ return None
133
+
134
+ try:
135
+ # Preprocess for OCR — invert if needed, sharpen
136
+ if len(image.shape) == 3:
137
+ gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
138
+ else:
139
+ gray = image.copy()
140
+
141
+ # Upscale small images for better OCR
142
+ h, w = gray.shape[:2]
143
+ if max(h, w) < 1000:
144
+ scale_up = 1000 / max(h, w)
145
+ gray = cv2.resize(gray, None, fx=scale_up, fy=scale_up,
146
+ interpolation=cv2.INTER_LINEAR)
147
+
148
+ # Threshold for cleaner text
149
+ _, thresh = cv2.threshold(gray, 0, 255,
150
+ cv2.THRESH_BINARY + cv2.THRESH_OTSU)
151
+
152
+ text = pytesseract.image_to_string(
153
+ thresh,
154
+ config='--psm 11 -c tessedit_char_whitelist=0123456789.,m c'
155
+ )
156
+
157
+ scale = self._parse_dimension_text(text, image.shape)
158
+ if scale is not None:
159
+ return scale
160
+
161
+ except Exception:
162
+ pass
163
+
164
+ return None
165
+
166
+ def _parse_dimension_text(
167
+ self, text: str, image_shape: tuple
168
+ ) -> Optional[ScaleEstimate]:
169
+ """Parse OCR text to find dimension annotations."""
170
+ h, w = image_shape[:2]
171
+ img_size = max(h, w)
172
+
173
+ # Patterns: "4.5m", "4,500mm", "450cm", "4.5 m"
174
+ patterns = [
175
+ (r'(\d+\.?\d*)\s*m(?:etres?|eters?)?\b(?!m)', 1.0), # metres
176
+ (r'(\d+\.?\d*)\s*cm\b', 0.01), # cm → m
177
+ (r'(\d{3,5})\s*mm\b', 0.001), # mm → m
178
+ ]
179
+
180
+ found_dims = []
181
+ for pattern, multiplier in patterns:
182
+ for match in re.finditer(pattern, text, re.IGNORECASE):
183
+ value_m = float(match.group(1)) * multiplier
184
+ if 0.5 <= value_m <= 50.0: # sanity check: 0.5m to 50m
185
+ found_dims.append(value_m)
186
+
187
+ if not found_dims:
188
+ return None
189
+
190
+ # Use median dimension as reference
191
+ ref_dim_m = float(np.median(found_dims))
192
+
193
+ # Assume the reference dimension spans ~40% of the image
194
+ ref_dim_px = img_size * 0.4
195
+ pixels_per_metre = ref_dim_px / ref_dim_m
196
+
197
+ return ScaleEstimate(
198
+ pixels_per_metre=pixels_per_metre,
199
+ confidence=0.75,
200
+ method="ocr",
201
+ notes=f"Detected dimension: {ref_dim_m:.2f}m from OCR",
202
+ )
203
+
204
+ def _estimate_from_rooms(self, vectorization_result) -> Optional[ScaleEstimate]:
205
+ """
206
+ Use known room size priors to estimate scale.
207
+ Compares detected room pixel areas to expected real-world areas.
208
+ """
209
+ scale_estimates = []
210
+
211
+ for room in vectorization_result.rooms:
212
+ class_name = room.class_name
213
+ if class_name not in ROOM_SIZE_PRIORS:
214
+ continue
215
+
216
+ min_m2, max_m2 = ROOM_SIZE_PRIORS[class_name]
217
+ mid_m2 = (min_m2 + max_m2) / 2.0
218
+ area_px = room.area
219
+
220
+ if area_px <= 0:
221
+ continue
222
+
223
+ # pixels_per_metre² = area_px / mid_m2
224
+ # pixels_per_metre = sqrt(area_px / mid_m2)
225
+ ppm = (area_px / mid_m2) ** 0.5
226
+ scale_estimates.append(ppm)
227
+
228
+ if not scale_estimates:
229
+ return None
230
+
231
+ # Use median to be robust against outliers
232
+ ppm = float(np.median(scale_estimates))
233
+
234
+ # Sanity check: 10–500 px/m is reasonable
235
+ if not (10 <= ppm <= 500):
236
+ return None
237
+
238
+ return ScaleEstimate(
239
+ pixels_per_metre=ppm,
240
+ confidence=0.55,
241
+ method="room_size",
242
+ notes=f"Estimated from {len(scale_estimates)} room(s)",
243
+ )
244
+
245
+ def _fallback_estimate(self, image: np.ndarray) -> ScaleEstimate:
246
+ """
247
+ Fallback: assume standard floor plan proportions.
248
+ A 1024px image typically represents a ~10-15m building footprint.
249
+ """
250
+ h, w = image.shape[:2]
251
+ img_size = max(h, w)
252
+
253
+ # Assume building footprint ≈ 12m on the longer axis
254
+ assumed_building_size_m = 12.0
255
+ ppm = img_size / assumed_building_size_m
256
+
257
+ return ScaleEstimate(
258
+ pixels_per_metre=ppm,
259
+ confidence=0.30,
260
+ method="fallback",
261
+ notes=f"Fallback: assumed {assumed_building_size_m}m building at {img_size}px",
262
+ )
src/geometry/wall_vectorizer.py ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ wall_vectorizer.py
3
+ ------------------
4
+ Converts YOLOv8 segmentation masks into clean 2D wall polygons.
5
+
6
+ Pipeline per mask:
7
+ 1. Binarize mask
8
+ 2. Morphological cleanup (close gaps, remove noise)
9
+ 3. Find contours
10
+ 4. Approximate contours to simplified polygons (Douglas-Peucker)
11
+ 5. Filter by area and aspect ratio
12
+ 6. Return list of WallPolygon objects
13
+
14
+ Usage:
15
+ from src.geometry.wall_vectorizer import WallVectorizer
16
+
17
+ vectorizer = WallVectorizer()
18
+ walls = vectorizer.extract(segmentation_result, image_shape)
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass, field
24
+ from typing import Optional
25
+ import cv2
26
+ import numpy as np
27
+
28
+
29
+ # ── Data structures ───────────────────────────────────────────────────────────
30
+
31
+ @dataclass
32
+ class WallPolygon:
33
+ """A single vectorized wall or room boundary polygon."""
34
+ class_id: int
35
+ class_name: str
36
+ points: list[tuple[int, int]] # pixel coordinates (x, y)
37
+ area: float # pixel area
38
+ bbox: tuple[int, int, int, int] # (x, y, w, h)
39
+ confidence: float = 1.0
40
+
41
+ @property
42
+ def is_wall(self) -> bool:
43
+ return self.class_id in (0, 1) # OuterWall, InnerWall
44
+
45
+ @property
46
+ def is_room(self) -> bool:
47
+ return self.class_id in (6, 7, 8, 9, 10, 11, 12)
48
+
49
+ @property
50
+ def centroid(self) -> tuple[float, float]:
51
+ if not self.points:
52
+ return (0.0, 0.0)
53
+ xs = [p[0] for p in self.points]
54
+ ys = [p[1] for p in self.points]
55
+ return (sum(xs) / len(xs), sum(ys) / len(ys))
56
+
57
+ def to_numpy(self) -> np.ndarray:
58
+ """Return points as (N, 2) numpy array."""
59
+ return np.array(self.points, dtype=np.int32)
60
+
61
+
62
+ @dataclass
63
+ class VectorizationResult:
64
+ """All vectorized elements from one floor plan."""
65
+ walls: list[WallPolygon] = field(default_factory=list)
66
+ rooms: list[WallPolygon] = field(default_factory=list)
67
+ doors: list[WallPolygon] = field(default_factory=list)
68
+ windows: list[WallPolygon] = field(default_factory=list)
69
+ other: list[WallPolygon] = field(default_factory=list)
70
+ image_shape: tuple[int, int] = (0, 0)
71
+
72
+ @property
73
+ def all_polygons(self) -> list[WallPolygon]:
74
+ return self.walls + self.rooms + self.doors + self.windows + self.other
75
+
76
+ @property
77
+ def summary(self) -> dict:
78
+ return {
79
+ "walls": len(self.walls),
80
+ "rooms": len(self.rooms),
81
+ "doors": len(self.doors),
82
+ "windows": len(self.windows),
83
+ "other": len(self.other),
84
+ "total": len(self.all_polygons),
85
+ }
86
+
87
+
88
+ # ── Vectorizer ────────────────────────────────────────────────────────────────
89
+
90
+ class WallVectorizer:
91
+ """
92
+ Converts segmentation masks into clean 2D vector polygons.
93
+
94
+ Args:
95
+ epsilon_factor: Douglas-Peucker approximation factor
96
+ (fraction of arc length). Lower = more detail.
97
+ min_area: Discard polygons smaller than this (px²).
98
+ morph_kernel: Kernel size for morphological cleanup.
99
+ simplify_walls: Extra simplification pass for wall polygons.
100
+ """
101
+
102
+ # Which class_ids map to which category (0-indexed, background excluded)
103
+ WALL_IDS = {0, 1} # OuterWall, InnerWall
104
+ DOOR_IDS = {3} # Door
105
+ WINDOW_IDS = {2} # Window
106
+ ROOM_IDS = {6, 7, 8, 9, 10, 11, 12} # room types
107
+
108
+ CLASS_NAMES = [
109
+ "OuterWall", "InnerWall", "Window", "Door", "Stairs",
110
+ "Railing", "Kitchen", "LivingRoom", "Bedroom", "Bathroom",
111
+ "Corridor", "Balcony", "Garage",
112
+ ]
113
+
114
+ def __init__(
115
+ self,
116
+ epsilon_factor: float = 0.008,
117
+ min_area: int = 200,
118
+ morph_kernel: int = 3,
119
+ simplify_walls: bool = True,
120
+ ):
121
+ self.epsilon_factor = epsilon_factor
122
+ self.min_area = min_area
123
+ self.morph_kernel = morph_kernel
124
+ self.simplify_walls = simplify_walls
125
+
126
+ def extract(
127
+ self,
128
+ segmentation_result,
129
+ image_shape: Optional[tuple] = None,
130
+ ) -> VectorizationResult:
131
+ """
132
+ Extract vector polygons from a SegmentationResult (Phase 2 output).
133
+
134
+ Args:
135
+ segmentation_result: FloorPlanPredictor result object.
136
+ image_shape: (H, W) of the source image.
137
+
138
+ Returns:
139
+ VectorizationResult with categorized polygons.
140
+ """
141
+ if image_shape is None:
142
+ image_shape = segmentation_result.image_shape
143
+
144
+ h, w = image_shape[:2]
145
+ result = VectorizationResult(image_shape=(h, w))
146
+
147
+ for element in segmentation_result.elements:
148
+ if element.mask is None:
149
+ continue
150
+
151
+ polygons = self._mask_to_polygons(
152
+ mask=element.mask,
153
+ class_id=element.class_id,
154
+ class_name=element.class_name,
155
+ confidence=element.confidence,
156
+ is_wall=(element.class_id in self.WALL_IDS),
157
+ )
158
+
159
+ for poly in polygons:
160
+ if poly.class_id in self.WALL_IDS:
161
+ result.walls.append(poly)
162
+ elif poly.class_id in self.DOOR_IDS:
163
+ result.doors.append(poly)
164
+ elif poly.class_id in self.WINDOW_IDS:
165
+ result.windows.append(poly)
166
+ elif poly.class_id in self.ROOM_IDS:
167
+ result.rooms.append(poly)
168
+ else:
169
+ result.other.append(poly)
170
+
171
+ return result
172
+
173
+ def extract_from_mask(
174
+ self,
175
+ mask: np.ndarray,
176
+ class_id: int,
177
+ class_name: str,
178
+ confidence: float = 1.0,
179
+ ) -> list[WallPolygon]:
180
+ """
181
+ Extract polygons directly from a binary mask array.
182
+ Useful for testing without a full SegmentationResult.
183
+ """
184
+ return self._mask_to_polygons(
185
+ mask=mask,
186
+ class_id=class_id,
187
+ class_name=class_name,
188
+ confidence=confidence,
189
+ is_wall=(class_id in self.WALL_IDS),
190
+ )
191
+
192
+ # ── Internal helpers ──────────────────────────────────────────────────────
193
+
194
+ def _mask_to_polygons(
195
+ self,
196
+ mask: np.ndarray,
197
+ class_id: int,
198
+ class_name: str,
199
+ confidence: float,
200
+ is_wall: bool,
201
+ ) -> list[WallPolygon]:
202
+ """Convert a binary mask to a list of simplified polygons."""
203
+
204
+ # Ensure binary uint8
205
+ binary = (mask > 127).astype(np.uint8) * 255
206
+
207
+ # Morphological cleanup
208
+ k = self.morph_kernel
209
+ kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (k, k))
210
+ binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)
211
+ binary = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel)
212
+
213
+ # Find external contours
214
+ contours, _ = cv2.findContours(
215
+ binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
216
+ )
217
+
218
+ polygons = []
219
+ for contour in contours:
220
+ area = cv2.contourArea(contour)
221
+ if area < self.min_area:
222
+ continue
223
+
224
+ # Douglas-Peucker simplification
225
+ epsilon = self.epsilon_factor * cv2.arcLength(contour, closed=True)
226
+
227
+ # Walls get extra simplification to remove noise
228
+ if is_wall and self.simplify_walls:
229
+ epsilon *= 1.5
230
+
231
+ approx = cv2.approxPolyDP(contour, epsilon, closed=True)
232
+
233
+ # Need at least 3 points for a valid polygon
234
+ if len(approx) < 3:
235
+ continue
236
+
237
+ points = [(int(pt[0][0]), int(pt[0][1])) for pt in approx]
238
+ x, y, w, h = cv2.boundingRect(contour)
239
+
240
+ polygons.append(WallPolygon(
241
+ class_id=class_id,
242
+ class_name=class_name,
243
+ points=points,
244
+ area=float(area),
245
+ bbox=(x, y, w, h),
246
+ confidence=confidence,
247
+ ))
248
+
249
+ # Sort by area descending (largest first)
250
+ polygons.sort(key=lambda p: p.area, reverse=True)
251
+ return polygons
252
+
253
+ def draw(
254
+ self,
255
+ image: np.ndarray,
256
+ result: VectorizationResult,
257
+ draw_labels: bool = True,
258
+ ) -> np.ndarray:
259
+ """
260
+ Draw vectorized polygons on an image for visualization.
261
+
262
+ Returns annotated BGR image.
263
+ """
264
+ if len(image.shape) == 2:
265
+ canvas = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
266
+ else:
267
+ canvas = image.copy()
268
+
269
+ colors = {
270
+ "wall": (50, 50, 200),
271
+ "door": (50, 200, 200),
272
+ "window": (200, 180, 50),
273
+ "room": (50, 180, 80),
274
+ "other": (150, 150, 150),
275
+ }
276
+
277
+ def draw_poly(polys, color, label_prefix=""):
278
+ for poly in polys:
279
+ pts = np.array(poly.points, dtype=np.int32)
280
+ cv2.polylines(canvas, [pts], isClosed=True,
281
+ color=color, thickness=2)
282
+ if draw_labels:
283
+ cx, cy = int(poly.centroid[0]), int(poly.centroid[1])
284
+ cv2.putText(canvas, poly.class_name,
285
+ (cx, cy), cv2.FONT_HERSHEY_SIMPLEX,
286
+ 0.4, color, 1, cv2.LINE_AA)
287
+
288
+ draw_poly(result.walls, colors["wall"])
289
+ draw_poly(result.doors, colors["door"])
290
+ draw_poly(result.windows, colors["window"])
291
+ draw_poly(result.rooms, colors["room"])
292
+ draw_poly(result.other, colors["other"])
293
+
294
+ return canvas
src/preprocessing/__init__.py ADDED
File without changes
src/preprocessing/binarizer.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ binarizer.py
3
+ ------------
4
+ Converts a grayscale floor plan image into a clean binary (black/white) image.
5
+
6
+ Pipeline:
7
+ 1. Gaussian blur → reduce sensor/scan noise
8
+ 2. Adaptive threshold → handle uneven lighting across the page
9
+ 3. Morphological close → fill tiny gaps in wall lines
10
+ 4. Morphological open → remove isolated specks
11
+ """
12
+
13
+ import cv2
14
+ import numpy as np
15
+
16
+
17
+ def binarize(
18
+ img: np.ndarray,
19
+ blur_kernel: int = 5,
20
+ block_size: int = 25,
21
+ c_offset: int = 10,
22
+ morph_kernel: int = 3,
23
+ ) -> np.ndarray:
24
+ """
25
+ Convert a grayscale image to a clean binary image.
26
+
27
+ Args:
28
+ img: Grayscale uint8 numpy array.
29
+ blur_kernel: Gaussian blur kernel size (must be odd).
30
+ block_size: Neighbourhood size for adaptive threshold (must be odd, ≥3).
31
+ c_offset: Constant subtracted from the mean in adaptive threshold.
32
+ Higher = more aggressive (removes faint lines too).
33
+ morph_kernel: Kernel size for morphological cleanup.
34
+
35
+ Returns:
36
+ Binary image (0 = background, 255 = foreground/walls), uint8.
37
+ """
38
+ _validate_grayscale(img)
39
+
40
+ # 1. Gaussian blur to suppress scan noise
41
+ blurred = cv2.GaussianBlur(img, (blur_kernel, blur_kernel), 0)
42
+
43
+ # 2. Adaptive threshold — handles uneven illumination better than Otsu
44
+ # THRESH_BINARY_INV: walls/lines become white (255) on black background
45
+ binary = cv2.adaptiveThreshold(
46
+ blurred,
47
+ maxValue=255,
48
+ adaptiveMethod=cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
49
+ thresholdType=cv2.THRESH_BINARY_INV,
50
+ blockSize=block_size,
51
+ C=c_offset,
52
+ )
53
+
54
+ # 3. Morphological closing: fills small breaks in wall lines
55
+ close_kernel = cv2.getStructuringElement(
56
+ cv2.MORPH_RECT, (morph_kernel, morph_kernel)
57
+ )
58
+ binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, close_kernel)
59
+
60
+ # 4. Morphological opening: removes isolated noise specks
61
+ open_kernel = cv2.getStructuringElement(
62
+ cv2.MORPH_RECT, (morph_kernel, morph_kernel)
63
+ )
64
+ binary = cv2.morphologyEx(binary, cv2.MORPH_OPEN, open_kernel)
65
+
66
+ return binary
67
+
68
+
69
+ def remove_small_components(
70
+ binary: np.ndarray, min_area: int = 100
71
+ ) -> np.ndarray:
72
+ """
73
+ Remove connected components smaller than min_area pixels.
74
+ Useful for eliminating text fragments and scan artifacts.
75
+
76
+ Args:
77
+ binary: Binary image (uint8, values 0 or 255).
78
+ min_area: Components with fewer pixels than this are removed.
79
+
80
+ Returns:
81
+ Cleaned binary image.
82
+ """
83
+ _validate_grayscale(binary)
84
+
85
+ num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(
86
+ binary, connectivity=8
87
+ )
88
+
89
+ # Background is label 0 — skip it
90
+ cleaned = np.zeros_like(binary)
91
+ for label in range(1, num_labels):
92
+ area = stats[label, cv2.CC_STAT_AREA]
93
+ if area >= min_area:
94
+ cleaned[labels == label] = 255
95
+
96
+ return cleaned
97
+
98
+
99
+ def enhance_contrast(img: np.ndarray) -> np.ndarray:
100
+ """
101
+ Apply CLAHE (Contrast Limited Adaptive Histogram Equalization).
102
+ Improves visibility of faint lines before thresholding.
103
+
104
+ Args:
105
+ img: Grayscale uint8 numpy array.
106
+
107
+ Returns:
108
+ Contrast-enhanced grayscale image.
109
+ """
110
+ _validate_grayscale(img)
111
+ clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
112
+ return clahe.apply(img)
113
+
114
+
115
+ def _validate_grayscale(img: np.ndarray) -> None:
116
+ if img is None or not isinstance(img, np.ndarray):
117
+ raise TypeError("Input must be a numpy ndarray.")
118
+ if len(img.shape) != 2:
119
+ raise ValueError(
120
+ f"Expected a grayscale (2D) image, got shape {img.shape}. "
121
+ "Convert to grayscale first."
122
+ )
src/preprocessing/loader.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ loader.py
3
+ ---------
4
+ Handles loading floor plan images from disk.
5
+ Supports: PNG, JPG, JPEG, BMP, TIFF, PDF (first page via PIL).
6
+ Normalizes to a standard resolution while preserving aspect ratio.
7
+ """
8
+
9
+ import cv2
10
+ import numpy as np
11
+ from PIL import Image
12
+ from pathlib import Path
13
+
14
+
15
+ SUPPORTED_FORMATS = {".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".tif", ".pdf"}
16
+
17
+
18
+ def load_image(image_path: str, target_size: int = 1024) -> np.ndarray:
19
+ """
20
+ Load a floor plan image and normalize it to a standard size.
21
+
22
+ Args:
23
+ image_path: Path to the input image file.
24
+ target_size: The longer dimension will be resized to this value.
25
+ Aspect ratio is preserved.
26
+
27
+ Returns:
28
+ Grayscale numpy array of shape (H, W), dtype uint8.
29
+
30
+ Raises:
31
+ FileNotFoundError: If the file does not exist.
32
+ ValueError: If the file format is not supported.
33
+ """
34
+ path = Path(image_path)
35
+
36
+ if not path.exists():
37
+ raise FileNotFoundError(f"Image not found: {image_path}")
38
+
39
+ if path.suffix.lower() not in SUPPORTED_FORMATS:
40
+ raise ValueError(
41
+ f"Unsupported format '{path.suffix}'. "
42
+ f"Supported: {SUPPORTED_FORMATS}"
43
+ )
44
+
45
+ # PDF: extract first page as image
46
+ if path.suffix.lower() == ".pdf":
47
+ img = _load_pdf_page(path)
48
+ else:
49
+ img = _load_raster(path)
50
+
51
+ # Convert to grayscale if needed
52
+ if len(img.shape) == 3:
53
+ img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
54
+
55
+ # Resize to target_size along the longer dimension
56
+ img = _resize_keep_aspect(img, target_size)
57
+
58
+ return img
59
+
60
+
61
+ def _load_raster(path: Path) -> np.ndarray:
62
+ """Load PNG/JPG/BMP/TIFF using OpenCV."""
63
+ img = cv2.imread(str(path), cv2.IMREAD_UNCHANGED)
64
+ if img is None:
65
+ raise IOError(f"OpenCV could not read image: {path}")
66
+ return img
67
+
68
+
69
+ def _load_pdf_page(path: Path, page: int = 0, dpi: int = 200) -> np.ndarray:
70
+ """
71
+ Load the first page of a PDF as a numpy array using PIL.
72
+ Requires Pillow with PDF support.
73
+ """
74
+ try:
75
+ pil_img = Image.open(str(path))
76
+ pil_img.load()
77
+ # For multi-page PDFs, seek to desired page
78
+ if hasattr(pil_img, "n_frames") and pil_img.n_frames > page:
79
+ pil_img.seek(page)
80
+ pil_img = pil_img.convert("RGB")
81
+ return cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
82
+ except Exception as e:
83
+ raise IOError(f"Failed to load PDF '{path}': {e}")
84
+
85
+
86
+ def _resize_keep_aspect(img: np.ndarray, target_size: int) -> np.ndarray:
87
+ """
88
+ Resize image so its longer dimension equals target_size.
89
+ Uses INTER_AREA for downscaling (best quality for line drawings).
90
+ """
91
+ h, w = img.shape[:2]
92
+ if max(h, w) == target_size:
93
+ return img
94
+
95
+ scale = target_size / max(h, w)
96
+ new_w = int(w * scale)
97
+ new_h = int(h * scale)
98
+
99
+ interpolation = cv2.INTER_AREA if scale < 1 else cv2.INTER_LINEAR
100
+ return cv2.resize(img, (new_w, new_h), interpolation=interpolation)
101
+
102
+
103
+ def save_image(img: np.ndarray, output_path: str) -> None:
104
+ """Save a numpy array as an image file."""
105
+ Path(output_path).parent.mkdir(parents=True, exist_ok=True)
106
+ cv2.imwrite(output_path, img)
107
+ print(f"Saved: {output_path}")