Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions backend/src/visual_graph/spatial/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
60 changes: 60 additions & 0 deletions backend/src/visual_graph/spatial/bbox.py
Original file line number Diff line number Diff line change
@@ -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
63 changes: 63 additions & 0 deletions backend/src/visual_graph/spatial/engine.py
Original file line number Diff line number Diff line change
@@ -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
133 changes: 133 additions & 0 deletions backend/src/visual_graph/spatial/relations.py
Original file line number Diff line number Diff line change
@@ -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)
52 changes: 52 additions & 0 deletions docs/spatial-relationships.md
Original file line number Diff line number Diff line change
@@ -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.
Loading