room-visualizer-api / backend /app /modules /floor_plane.py
Johnntirs's picture
Room Visualizer backend (Docker)
0cdb4e8
Raw
History Blame Contribute Delete
2.77 kB
"""Floor-plane + perspective estimation (spec section 5, stage 2).
Combines the floor mask (SAM or geometric) with a simple pinhole-style ground
model. Full 3D reconstruction is intentionally OUT — a horizon estimate plus a
row-based scale function is sufficient for MVP placement.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import cv2
import numpy as np
from ..config import settings
from . import segmentation
@dataclass
class Perspective:
"""Row-based ground-plane scale model.
``ppc(y)`` gives lateral pixels-per-cm for an object resting on the floor at
image row ``y``: large near the camera (image bottom), shrinking to ~0 toward
the horizon. This is the heuristic used when no metric depth is available.
"""
width: int
height: int
horizon_y: float
floor_width_cm_bottom: float
foreshorten: float
def ppc(self, y: float) -> float:
denom = max(1.0, self.height - self.horizon_y)
t = (y - self.horizon_y) / denom # 0 at horizon, 1 at bottom
t = max(0.02, min(1.0, t)) # clamp away from zero
return (self.width / self.floor_width_cm_bottom) * t
@dataclass
class FloorPlane:
perspective: Perspective
polygon: list[list[float]] # floor polygon [[x, y], ...] in pixels
mask: np.ndarray # HxW uint8 (255 = floor)
def estimate_floor_plane(
image_path: Path, floor_mask: np.ndarray | None = None
) -> FloorPlane:
if floor_mask is None:
floor_mask = segmentation.floor_mask(image_path)
h, w = floor_mask.shape[:2]
# Horizon ~ the highest floor row (where floor meets the back wall).
rows_with_floor = np.where(floor_mask.max(axis=1) > 0)[0]
horizon_y = float(rows_with_floor.min()) if rows_with_floor.size else h * settings.HORIZON_FRAC
persp = Perspective(
width=w,
height=h,
horizon_y=horizon_y,
floor_width_cm_bottom=settings.ROOM_FLOOR_WIDTH_CM,
foreshorten=settings.DEPTH_FORESHORTEN,
)
return FloorPlane(perspective=persp, polygon=_mask_to_polygon(floor_mask), mask=floor_mask)
def _mask_to_polygon(mask: np.ndarray, max_points: int = 24) -> list[list[float]]:
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
h, w = mask.shape[:2]
return [[0, h - 1], [w - 1, h - 1], [w - 1, 0], [0, 0]]
c = max(contours, key=cv2.contourArea)
eps = 0.01 * cv2.arcLength(c, True)
approx = cv2.approxPolyDP(c, eps, True).reshape(-1, 2)
if len(approx) > max_points:
idx = np.linspace(0, len(approx) - 1, max_points).astype(int)
approx = approx[idx]
return [[float(x), float(y)] for x, y in approx]