File size: 3,539 Bytes
0cdb4e8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
"""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