Spaces:
Sleeping
Sleeping
| """Shapely geometry: usable floor polygon, blocked areas, placement footprint. | |
| This is load-bearing (spec section 5, stage 3): it decides WHERE furniture goes | |
| and, with scaling.py, HOW BIG it is. A fixed-size sprite dropped in the middle of | |
| the image is an explicit failure of the spec — placement here is fitted to the | |
| empty floor and perspective. | |
| """ | |
| from __future__ import annotations | |
| from dataclasses import dataclass | |
| from shapely.geometry import Polygon, box | |
| from shapely.ops import unary_union | |
| from . import scaling | |
| from .detection import Detection | |
| from .floor_plane import Perspective | |
| class SpaceAnalysis: | |
| floor: Polygon | |
| usable: Polygon | |
| blocked: list[Polygon] | |
| free_ratio: float | |
| def _largest_polygon(geom): | |
| if geom.is_empty or geom.geom_type == "Polygon": | |
| return geom | |
| polys = [g for g in getattr(geom, "geoms", []) if g.geom_type == "Polygon"] | |
| return max(polys, key=lambda p: p.area) if polys else geom | |
| def compute_spaces( | |
| floor_polygon: list[list[float]], detections: list[Detection], persp: Perspective | |
| ) -> SpaceAnalysis: | |
| """Subtract detected object footprints from the floor to get usable space.""" | |
| floor = Polygon(floor_polygon) | |
| if not floor.is_valid: | |
| floor = floor.buffer(0) | |
| blocked: list[Polygon] = [] | |
| for det in detections: | |
| x1, y1, x2, y2 = det.box | |
| # Floor contact ≈ lower ~45% of the bounding box. | |
| foot = box(x1, y1 + (y2 - y1) * 0.55, x2, y2) | |
| inter = floor.intersection(foot) | |
| if not inter.is_empty and inter.area > 0: | |
| blocked.append(_largest_polygon(inter)) | |
| blocked_union = unary_union(blocked) if blocked else None | |
| usable = floor.difference(blocked_union) if blocked_union is not None else floor | |
| usable = _largest_polygon(usable) | |
| free_ratio = (usable.area / floor.area) if floor.area > 0 else 0.0 | |
| return SpaceAnalysis(floor=floor, usable=usable, blocked=blocked, free_ratio=free_ratio) | |
| def select_placement( | |
| usable: Polygon, persp: Perspective, width_cm: float, depth_cm: float | |
| ) -> tuple[tuple[float, float], list[list[float]], bool]: | |
| """Find an anchor + perspective footprint that fits inside the usable floor. | |
| Scans anchors from near (image bottom) to mid-depth, centered then offset. | |
| Returns (anchor_xy, footprint_quad, fitted). If nothing fits cleanly, returns | |
| the best-overlap candidate so the caller always has a placement to mask. | |
| """ | |
| minx, _miny, maxx, maxy = usable.bounds | |
| H = persp.height | |
| cx = (minx + maxx) / 2.0 | |
| span = max(1.0, maxx - minx) | |
| best: tuple[float, tuple[float, float], list[list[float]]] | None = None | |
| for ry in (0.95, 0.90, 0.85, 0.80, 0.72, 0.64, 0.56): | |
| y_a = H * ry | |
| if y_a <= persp.horizon_y + 5 or y_a > maxy + 5: | |
| continue | |
| for dx in (0.0, -0.12, 0.12, -0.24, 0.24): | |
| x_a = cx + dx * span | |
| quad = scaling.footprint_quad(persp, (x_a, y_a), width_cm, depth_cm) | |
| poly = Polygon(quad) | |
| if not poly.is_valid or poly.area <= 0: | |
| continue | |
| if usable.contains(poly): | |
| return (x_a, y_a), quad, True | |
| ratio = usable.intersection(poly).area / poly.area | |
| if best is None or ratio > best[0]: | |
| best = (ratio, (x_a, y_a), quad) | |
| if best is None: | |
| y_a = min(maxy, H * 0.9) | |
| quad = scaling.footprint_quad(persp, (cx, y_a), width_cm, depth_cm) | |
| return (cx, y_a), quad, False | |
| return best[1], best[2], False | |