Johnntirs's picture
Room Visualizer backend (Docker)
0cdb4e8
Raw
History Blame Contribute Delete
3.54 kB
"""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
@dataclass
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