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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Copy to .env and adjust. .env is ignored by git.

# --- Neo4j container ---
NEO4J_USER=neo4j
NEO4J_PASSWORD=change-me-please
NEO4J_HTTP_PORT=7474
NEO4J_BOLT_PORT=7687

# --- Backend connection to Neo4j ---
NEO4J_URI=bolt://localhost:7687
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,29 @@ OVERLAPS

Future versions may explore image embeddings, vector search, semantic relationships, and hybrid visual search.

## Local development

### Neo4j

Requirements: Docker with Compose, and [uv](https://docs.astral.sh/uv/) for the backend.

```bash
cp .env.example .env # then set NEO4J_PASSWORD (.env is not versioned)
docker compose up -d --wait # starts Neo4j and waits until it is healthy
```

* Neo4j Browser: <http://localhost:7474> (Connect URL `bolt://localhost:7687`, user/password from `.env`)
* Data and logs are stored in `./neo4j/` (git-ignored). Stop with `docker compose down`;
reset the database with `docker compose down` followed by deleting `./neo4j/data`.

Verify the backend can connect (needs the container running):

```bash
cd backend
uv sync
uv run pytest -m integration
```

## License

This project is licensed under the MIT License.
3 changes: 2 additions & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.30",
"pydantic-settings>=2.4",
"neo4j>=5.26",
]

[project.scripts]
Expand Down Expand Up @@ -47,4 +48,4 @@ select = ["E", "F", "I", "UP"]

[tool.mypy]
files = ["src"]
strict = true
strict = true
9 changes: 7 additions & 2 deletions backend/src/visual_graph/config.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
from functools import lru_cache

from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
model_config = SettingsConfigDict(env_file=("../.env", ".env"), extra="ignore")

app_name: str = "VisualGraph"
api_host: str = "127.0.0.1"
api_port: int = 8000
api_reload: bool = False

neo4j_uri: str = "bolt://localhost:7687"
neo4j_user: str = "neo4j"
neo4j_password: SecretStr | None = None


@lru_cache
def get_settings() -> Settings:
return Settings()
return Settings()
30 changes: 30 additions & 0 deletions backend/src/visual_graph/graph/connection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""Neo4j driver factory."""

from neo4j import Driver, GraphDatabase

from visual_graph.config import Settings, get_settings


class MissingCredentialsError(RuntimeError):
"""Raised when NEO4J_PASSWORD is not configured."""


def create_driver(settings: Settings | None = None) -> Driver:
settings = settings or get_settings()
if settings.neo4j_password is None:
raise MissingCredentialsError(
"NEO4J_PASSWORD is not set. Copy .env.example to .env and set it."
)
return GraphDatabase.driver(
settings.neo4j_uri,
auth=(settings.neo4j_user, settings.neo4j_password.get_secret_value()),
)


def verify_connection(driver: Driver) -> None:
"""Raise if the database is unreachable or credentials are wrong."""
driver.verify_connectivity()
with driver.session() as session:
record = session.run("RETURN 1 AS ok").single()
if record is None or record["ok"] != 1:
raise RuntimeError("Unexpected response from Neo4j")
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)
13 changes: 13 additions & 0 deletions backend/tests/integration/test_neo4j_connection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import pytest

from visual_graph.graph.connection import create_driver, verify_connection

pytestmark = pytest.mark.integration


def test_backend_can_connect_to_neo4j() -> None:
driver = create_driver()
try:
verify_connection(driver)
finally:
driver.close()
Loading
Loading