diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2e47a17 --- /dev/null +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 88b87ac..9dd48bf 100644 --- a/README.md +++ b/README.md @@ -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: (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. diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 925695b..8d9d142 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "fastapi>=0.115", "uvicorn[standard]>=0.30", "pydantic-settings>=2.4", + "neo4j>=5.26", ] [project.scripts] @@ -47,4 +48,4 @@ select = ["E", "F", "I", "UP"] [tool.mypy] files = ["src"] -strict = true \ No newline at end of file +strict = true diff --git a/backend/src/visual_graph/config.py b/backend/src/visual_graph/config.py index b82b035..cde8a34 100644 --- a/backend/src/visual_graph/config.py +++ b/backend/src/visual_graph/config.py @@ -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() \ No newline at end of file + return Settings() diff --git a/backend/src/visual_graph/graph/connection.py b/backend/src/visual_graph/graph/connection.py new file mode 100644 index 0000000..3c7500a --- /dev/null +++ b/backend/src/visual_graph/graph/connection.py @@ -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") 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/backend/tests/integration/test_neo4j_connection.py b/backend/tests/integration/test_neo4j_connection.py new file mode 100644 index 0000000..d1a8c9b --- /dev/null +++ b/backend/tests/integration/test_neo4j_connection.py @@ -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() diff --git a/backend/tests/unit/test_connection.py b/backend/tests/unit/test_connection.py new file mode 100644 index 0000000..2315f48 --- /dev/null +++ b/backend/tests/unit/test_connection.py @@ -0,0 +1,10 @@ +import pytest + +from visual_graph.config import Settings +from visual_graph.graph.connection import MissingCredentialsError, create_driver + + +def test_missing_password_raises() -> None: + settings = Settings(_env_file=None, neo4j_password=None) # type: ignore[call-arg] + with pytest.raises(MissingCredentialsError): + create_driver(settings) diff --git a/backend/uv.lock b/backend/uv.lock index 34ef81e..fa3ab9c 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -575,6 +575,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "neo4j" +version = "6.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/d1/bc40a291c60b06738ce92d5f0259d348eee1f07ba10d569a881c848c8afe/neo4j-6.4.1.tar.gz", hash = "sha256:fac99b7cb9679b93ef97dc23f22a3f0e7c3feb2db223eddfd2521f27005cfeaf", size = 287897, upload-time = "2026-10-15T10:47:26.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/69/3036bdcacfba561c6ef4b00fe72529e0a1b6241a6b2de82572a0940a276b/neo4j-6.4.1-py3-none-any.whl", hash = "sha256:f56a281c74355522fc2642df8919bec450115431531b8f3f6fe1cc17acb57a27", size = 390684, upload-time = "2026-10-15T10:47:25.236Z" }, +] + [[package]] name = "opentelemetry-api" version = "1.45.0" @@ -766,6 +778,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, ] +[[package]] +name = "pytz" +version = "2026.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/b0/168030b0bfcc334cf09fa3313afdb888d892c8a0027324683d1de0ec277e/pytz-2026.6.tar.gz", hash = "sha256:3b0fc2727707663d6e517936f444fd8a8b240f1fc32f4bf7ee03274245945284", size = 318520, upload-time = "2026-10-29T09:05:47.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/25/564f3fc5b70c1398d706c51f2b1470d0ea0b1b2f96fee67877b6f5bdf3b6/pytz-2026.6-py2.py3-none-any.whl", hash = "sha256:7fed3fee8fd6bd4a2b7b75a8d56021d0624127bf69bd5691f115db929ac570b3", size = 506321, upload-time = "2026-10-29T09:05:45.14Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -941,6 +962,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "fastapi" }, + { name = "neo4j" }, { name = "pydantic-settings" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -958,6 +980,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.115" }, + { name = "neo4j", specifier = ">=5.26" }, { name = "pydantic-settings", specifier = ">=2.4" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, ] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..18054bd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +services: + neo4j: + image: neo4j:5.26-community + container_name: visual-graph-neo4j + restart: unless-stopped + ports: + - "${NEO4J_HTTP_PORT:-7474}:7474" # Neo4j Browser + - "${NEO4J_BOLT_PORT:-7687}:7687" # Bolt + environment: + NEO4J_AUTH: "${NEO4J_USER:-neo4j}/${NEO4J_PASSWORD:?Set NEO4J_PASSWORD in .env (see .env.example)}" + volumes: + - ./neo4j/data:/data + - ./neo4j/logs:/logs + healthcheck: + test: ["CMD-SHELL", "cypher-shell -u \"$${NEO4J_USER:-neo4j}\" -p \"$${NEO4J_AUTH#*/}\" 'RETURN 1' || exit 1"] + interval: 10s + timeout: 10s + retries: 12 + start_period: 20s 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.