Spaces:
Running
Running
| """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 | |
| 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 | |
| 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] | |