Spaces:
Sleeping
Sleeping
Harisri commited on
Commit ·
fc895f4
1
Parent(s): 0aa78a2
Purged CV model deployment
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +3 -0
- Dockerfile +46 -0
- api.py +90 -0
- endToEnd2.py +93 -0
- frontend/.gitignore +24 -0
- frontend/README.md +16 -0
- frontend/eslint.config.js +21 -0
- frontend/index.html +13 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +31 -0
- frontend/public/bg-video.mp4 +3 -0
- frontend/public/favicon.svg +1 -0
- frontend/public/icons.svg +24 -0
- frontend/src/App.css +184 -0
- frontend/src/App.jsx +160 -0
- frontend/src/assets/hero.png +3 -0
- frontend/src/assets/react.svg +1 -0
- frontend/src/assets/vite.svg +1 -0
- frontend/src/index.css +343 -0
- frontend/src/main.jsx +10 -0
- frontend/vite.config.js +7 -0
- models/best.pt +3 -0
- requirements.txt +39 -0
- samples/18_png.rf.4956b6043e9f9f738808088cfe37243d.jpg +3 -0
- samples/19_png.rf.5435466b5cc5a5cf9cbc1da0f911767b.jpg +3 -0
- samples/floorplan1.png +3 -0
- samples/floorplan2.png +3 -0
- samples/sample3.png +3 -0
- samples/upload_1ea7884c.jpg +3 -0
- samples/upload_6bc6cd93.jpg +3 -0
- samples/upload_7cfecab9.jpg +3 -0
- samples/upload_81a83d93.jpg +3 -0
- samples/upload_a931bf10.jpg +3 -0
- samples/upload_adad9a2a.jpg +3 -0
- samples/upload_b3613f65.jpg +3 -0
- samples/upload_b7f8874a.jpg +3 -0
- samples/upload_d5875a8c.jpg +3 -0
- samples/upload_e3e1e653.jpg +3 -0
- samples/upload_e8616a4f.jpg +3 -0
- samples/upload_ecd85a2a.jpg +3 -0
- src/detection/__init__.py +19 -0
- src/detection/refinement.py +1184 -0
- src/geometry/__init__.py +0 -0
- src/geometry/pipeline.py +372 -0
- src/geometry/room_graph.py +298 -0
- src/geometry/scale_estimator.py +262 -0
- src/geometry/wall_vectorizer.py +294 -0
- src/preprocessing/__init__.py +0 -0
- src/preprocessing/binarizer.py +122 -0
- 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
|
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
|
samples/19_png.rf.5435466b5cc5a5cf9cbc1da0f911767b.jpg
ADDED
|
Git LFS Details
|
samples/floorplan1.png
ADDED
|
Git LFS Details
|
samples/floorplan2.png
ADDED
|
Git LFS Details
|
samples/sample3.png
ADDED
|
Git LFS Details
|
samples/upload_1ea7884c.jpg
ADDED
|
Git LFS Details
|
samples/upload_6bc6cd93.jpg
ADDED
|
Git LFS Details
|
samples/upload_7cfecab9.jpg
ADDED
|
Git LFS Details
|
samples/upload_81a83d93.jpg
ADDED
|
Git LFS Details
|
samples/upload_a931bf10.jpg
ADDED
|
Git LFS Details
|
samples/upload_adad9a2a.jpg
ADDED
|
Git LFS Details
|
samples/upload_b3613f65.jpg
ADDED
|
Git LFS Details
|
samples/upload_b7f8874a.jpg
ADDED
|
Git LFS Details
|
samples/upload_d5875a8c.jpg
ADDED
|
Git LFS Details
|
samples/upload_e3e1e653.jpg
ADDED
|
Git LFS Details
|
samples/upload_e8616a4f.jpg
ADDED
|
Git LFS Details
|
samples/upload_ecd85a2a.jpg
ADDED
|
Git LFS Details
|
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}")
|