diff --git a/backend/src/visual_graph/spatial/__init__.py b/backend/src/visual_graph/spatial/__init__.py index e69de29..3f14be4 100644 --- a/backend/src/visual_graph/spatial/__init__.py +++ b/backend/src/visual_graph/spatial/__init__.py @@ -0,0 +1,13 @@ +from visual_graph.spatial.bbox import BoundingBox, InvalidBoundingBoxError +from visual_graph.spatial.engine import Relationship, SpatialObject, compute_relationships +from visual_graph.spatial.relations import RelationType, SpatialConfig + +__all__ = [ + "BoundingBox", + "InvalidBoundingBoxError", + "RelationType", + "Relationship", + "SpatialConfig", + "SpatialObject", + "compute_relationships", +] diff --git a/backend/src/visual_graph/spatial/bbox.py b/backend/src/visual_graph/spatial/bbox.py new file mode 100644 index 0000000..d08d60a --- /dev/null +++ b/backend/src/visual_graph/spatial/bbox.py @@ -0,0 +1,60 @@ +"""Bounding-box representation used by the spatial relationship engine. + +Coordinates follow the image convention: the origin is the top-left corner, +``x`` grows to the right and ``y`` grows downward. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass + + +class InvalidBoundingBoxError(ValueError): + """Raised when bounding-box coordinates do not describe a valid box.""" + + +@dataclass(frozen=True, slots=True) +class BoundingBox: + """Axis-aligned bounding box with strictly positive width and height.""" + + x_min: float + y_min: float + x_max: float + y_max: float + + def __post_init__(self) -> None: + coords = (self.x_min, self.y_min, self.x_max, self.y_max) + for value in coords: + if isinstance(value, bool) or not isinstance(value, int | float): + raise InvalidBoundingBoxError(f"coordinates must be numbers, got {value!r}") + if not math.isfinite(value): + raise InvalidBoundingBoxError(f"coordinates must be finite, got {value!r}") + if self.x_max <= self.x_min: + raise InvalidBoundingBoxError( + f"x_max ({self.x_max}) must be greater than x_min ({self.x_min})" + ) + if self.y_max <= self.y_min: + raise InvalidBoundingBoxError( + f"y_max ({self.y_max}) must be greater than y_min ({self.y_min})" + ) + + @property + def width(self) -> float: + return self.x_max - self.x_min + + @property + def height(self) -> float: + return self.y_max - self.y_min + + @property + def area(self) -> float: + return self.width * self.height + + @property + def center_x(self) -> float: + return (self.x_min + self.x_max) / 2 + + @property + def center_y(self) -> float: + return (self.y_min + self.y_max) / 2 diff --git a/backend/src/visual_graph/spatial/engine.py b/backend/src/visual_graph/spatial/engine.py new file mode 100644 index 0000000..3f60a2a --- /dev/null +++ b/backend/src/visual_graph/spatial/engine.py @@ -0,0 +1,63 @@ +"""Compute spatial relationships for all objects detected in one image.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +from visual_graph.spatial.bbox import BoundingBox +from visual_graph.spatial.relations import ( + DEFAULT_CONFIG, + SYMMETRIC_RELATIONS, + RelationType, + SpatialConfig, + holds, +) + + +@dataclass(frozen=True, slots=True) +class SpatialObject: + """An identified object with a bounding box (detector-agnostic).""" + + id: str + bbox: BoundingBox + + +@dataclass(frozen=True, slots=True) +class Relationship: + source_id: str + type: RelationType + target_id: str + + +def compute_relationships( + objects: Sequence[SpatialObject], + config: SpatialConfig = DEFAULT_CONFIG, +) -> list[Relationship]: + """Return every spatial relationship between distinct objects. + + * An object is never related to itself. + * Directional relations are emitted for each ordered pair. + * Symmetric relations (``NEAR``, ``OVERLAPS``) are emitted once per unordered + pair, from the object that appears first in ``objects``. + * The output order depends only on the input order, so equal inputs always + produce equal outputs. + + Raises: + ValueError: if two objects share the same id. + """ + ids = [obj.id for obj in objects] + if len(set(ids)) != len(ids): + raise ValueError("object ids must be unique") + + result: list[Relationship] = [] + for i, first in enumerate(objects): + for j, second in enumerate(objects): + if i == j: + continue + for relation in RelationType: + if relation in SYMMETRIC_RELATIONS and j < i: + continue + if holds(relation, first.bbox, second.bbox, config): + result.append(Relationship(first.id, relation, second.id)) + return result diff --git a/backend/src/visual_graph/spatial/relations.py b/backend/src/visual_graph/spatial/relations.py new file mode 100644 index 0000000..0f106aa --- /dev/null +++ b/backend/src/visual_graph/spatial/relations.py @@ -0,0 +1,133 @@ +"""Deterministic geometric predicates between two bounding boxes. + +Every predicate is a pure function of its inputs: it does not depend on Neo4j, +on a detector, or on any global state. See ``docs/spatial-relationships.md`` +for the mathematical definition of each rule. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from enum import StrEnum + +from visual_graph.spatial.bbox import BoundingBox + + +class RelationType(StrEnum): + LEFT_OF = "LEFT_OF" + RIGHT_OF = "RIGHT_OF" + ABOVE = "ABOVE" + BELOW = "BELOW" + NEAR = "NEAR" + OVERLAPS = "OVERLAPS" + + +#: Relations that hold in both directions; stored once per unordered pair. +SYMMETRIC_RELATIONS = frozenset({RelationType.NEAR, RelationType.OVERLAPS}) + + +@dataclass(frozen=True, slots=True) +class SpatialConfig: + """Explicit, configurable thresholds for the spatial rules. + + Attributes: + directional_margin: Fraction of the larger box dimension (width for + LEFT_OF/RIGHT_OF, height for ABOVE/BELOW) by which the centers must + differ. ``0.0`` means any strict center difference counts. + near_ratio: NEAR holds when the gap between the boxes is at most + ``near_ratio * max(width, height)`` of the larger box. + min_overlap_ratio: OVERLAPS requires the intersection area to be + strictly greater than ``min_overlap_ratio * min(area_a, area_b)``. + ``0.0`` means any positive-area intersection counts. + """ + + directional_margin: float = 0.0 + near_ratio: float = 0.5 + min_overlap_ratio: float = 0.0 + + def __post_init__(self) -> None: + if self.directional_margin < 0: + raise ValueError("directional_margin must be >= 0") + if self.near_ratio < 0: + raise ValueError("near_ratio must be >= 0") + if not 0 <= self.min_overlap_ratio < 1: + raise ValueError("min_overlap_ratio must be in [0, 1)") + + +DEFAULT_CONFIG = SpatialConfig() + + +def left_of(a: BoundingBox, b: BoundingBox, config: SpatialConfig = DEFAULT_CONFIG) -> bool: + """``a`` is left of ``b`` when ``b.cx - a.cx > margin * max(a.w, b.w)``.""" + return b.center_x - a.center_x > config.directional_margin * max(a.width, b.width) + + +def right_of(a: BoundingBox, b: BoundingBox, config: SpatialConfig = DEFAULT_CONFIG) -> bool: + """``a`` is right of ``b`` exactly when ``b`` is left of ``a``.""" + return left_of(b, a, config) + + +def above(a: BoundingBox, b: BoundingBox, config: SpatialConfig = DEFAULT_CONFIG) -> bool: + """``a`` is above ``b`` when ``b.cy - a.cy > margin * max(a.h, b.h)`` (y grows downward).""" + return b.center_y - a.center_y > config.directional_margin * max(a.height, b.height) + + +def below(a: BoundingBox, b: BoundingBox, config: SpatialConfig = DEFAULT_CONFIG) -> bool: + """``a`` is below ``b`` exactly when ``b`` is above ``a``.""" + return above(b, a, config) + + +def intersection_area(a: BoundingBox, b: BoundingBox) -> float: + """Area of the intersection of two boxes (``0.0`` if disjoint or touching).""" + dx = min(a.x_max, b.x_max) - max(a.x_min, b.x_min) + dy = min(a.y_max, b.y_max) - max(a.y_min, b.y_min) + if dx <= 0 or dy <= 0: + return 0.0 + return dx * dy + + +def gap_distance(a: BoundingBox, b: BoundingBox) -> float: + """Euclidean distance between the closest edges of two boxes (``0`` if they touch/overlap).""" + dx = max(a.x_min - b.x_max, b.x_min - a.x_max, 0.0) + dy = max(a.y_min - b.y_max, b.y_min - a.y_max, 0.0) + return math.hypot(dx, dy) + + +def overlaps(a: BoundingBox, b: BoundingBox, config: SpatialConfig = DEFAULT_CONFIG) -> bool: + """Boxes overlap when ``area(a ∩ b) > min_overlap_ratio * min(area a, area b)`` and > 0. + + Boxes that only touch along an edge or corner do not overlap. + """ + inter = intersection_area(a, b) + return inter > 0 and inter > config.min_overlap_ratio * min(a.area, b.area) + + +def near(a: BoundingBox, b: BoundingBox, config: SpatialConfig = DEFAULT_CONFIG) -> bool: + """Boxes are near when ``gap_distance <= near_ratio * max(w, h of the larger box)``. + + The threshold is inclusive. Overlapping or touching boxes have gap ``0`` and + are therefore near. + """ + scale = max(a.width, a.height, b.width, b.height) + return gap_distance(a, b) <= config.near_ratio * scale + + +_PREDICATES = { + RelationType.LEFT_OF: left_of, + RelationType.RIGHT_OF: right_of, + RelationType.ABOVE: above, + RelationType.BELOW: below, + RelationType.NEAR: near, + RelationType.OVERLAPS: overlaps, +} + + +def holds( + relation: RelationType, + a: BoundingBox, + b: BoundingBox, + config: SpatialConfig = DEFAULT_CONFIG, +) -> bool: + """Evaluate ``relation`` for the ordered pair ``(a, b)``.""" + return _PREDICATES[relation](a, b, config) diff --git a/docs/spatial-relationships.md b/docs/spatial-relationships.md new file mode 100644 index 0000000..3f0ec0f --- /dev/null +++ b/docs/spatial-relationships.md @@ -0,0 +1,52 @@ +# Spatial relationships + +Relationships are computed from axis-aligned bounding boxes using explicit +geometric rules (`backend/src/visual_graph/spatial/`). They do not depend on +Neo4j or on any particular detector. + +## Bounding box + +`BoundingBox(x_min, y_min, x_max, y_max)` in image coordinates (origin top-left, +`y` grows downward). Boxes must have finite numeric coordinates and strictly +positive width and height; zero-area, inverted or malformed boxes raise +`InvalidBoundingBoxError`. + +Derived: `width = x_max - x_min`, `height = y_max - y_min`, `area = width * height`, +`center_x = (x_min + x_max) / 2`, `center_y = (y_min + y_max) / 2`. + +## Rules (for the ordered pair `A, B`) + +| Relation | Definition | +|---|---| +| `LEFT_OF` | `B.center_x - A.center_x > directional_margin * max(A.width, B.width)` | +| `RIGHT_OF` | `B LEFT_OF A` | +| `ABOVE` | `B.center_y - A.center_y > directional_margin * max(A.height, B.height)` | +| `BELOW` | `B ABOVE A` | +| `OVERLAPS` | `area(A ∩ B) > 0` and `area(A ∩ B) > min_overlap_ratio * min(A.area, B.area)` | +| `NEAR` | `gap(A, B) <= near_ratio * max(A.width, A.height, B.width, B.height)` | + +`gap(A, B) = hypot(dx, dy)` with `dx = max(A.x_min - B.x_max, B.x_min - A.x_max, 0)` and +`dy` analogous: the Euclidean distance between the closest edges (`0` when the boxes touch or overlap). + +Notes: + +- Boxes that only touch along an edge or corner do **not** overlap (intersection area is 0) but are `NEAR`. +- `NEAR` uses an inclusive threshold and is relative to object size, so it is independent of image resolution. +- Directional rules compare centers, so a diagonal pair can satisfy both a horizontal and a vertical relation. + +## Configuration (`SpatialConfig`) + +| Field | Default | Meaning | +|---|---|---| +| `directional_margin` | `0.0` | Required center offset as a fraction of the larger box dimension | +| `near_ratio` | `0.5` | Maximum gap as a fraction of the larger box's longest side | +| `min_overlap_ratio` | `0.0` | Minimum intersection as a fraction of the smaller box's area (`[0, 1)`) | + +## Engine + +`compute_relationships(objects, config)` evaluates all pairs of distinct objects: + +- an object is never related to itself; duplicate ids raise `ValueError`; +- directional relations are emitted per ordered pair; +- `NEAR` and `OVERLAPS` are symmetric and emitted once per unordered pair (from the object listed first); +- output order depends only on input order, so equal inputs give equal outputs.