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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,23 @@ cd backend
uv run python -m visual_graph.graph
```

### Sample scene graph

Loads a small hand-made scene (person, dog, bicycle, two chairs) and is idempotent:

```bash
cd backend
uv run python -m visual_graph.graph.seed
```

Then open <http://localhost:7474> and run:

```cypher
MATCH p=(:Image {id: "sample-street-1"})-[*1..2]->() RETURN p
```

The seed lives in `backend/src/visual_graph/graph/cypher/seed_sample_scene.cypher`.

## License

This project is licensed under the MIT License.
51 changes: 51 additions & 0 deletions backend/src/visual_graph/graph/cypher/seed_sample_scene.cypher
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Sample scene graph created manually (no computer vision involved).
// Idempotent: uses MERGE, so it can be run repeatedly without duplicating data.
// Coordinates: origin top-left, y grows downward. Image is 1280x720.

MERGE (img:Image {id: "sample-street-1"})
SET img.uri = "samples/street.jpg", img.width = 1280, img.height = 720

MERGE (person:Class {name: "person"})
MERGE (dog:Class {name: "dog"})
MERGE (bicycle:Class {name: "bicycle"})
MERGE (chair:Class {name: "chair"})

MERGE (o_person:ObjectInstance {id: "sample-person-1"})
SET o_person.x_min = 100, o_person.y_min = 100, o_person.x_max = 300, o_person.y_max = 600
MERGE (o_dog:ObjectInstance {id: "sample-dog-1"})
SET o_dog.x_min = 320, o_dog.y_min = 450, o_dog.x_max = 420, o_dog.y_max = 600
MERGE (o_bike:ObjectInstance {id: "sample-bicycle-1"})
SET o_bike.x_min = 500, o_bike.y_min = 200, o_bike.x_max = 800, o_bike.y_max = 650
// Two chairs: several instances of the same class in one image.
MERGE (o_chair1:ObjectInstance {id: "sample-chair-1"})
SET o_chair1.x_min = 900, o_chair1.y_min = 300, o_chair1.x_max = 1050, o_chair1.y_max = 600
MERGE (o_chair2:ObjectInstance {id: "sample-chair-2"})
SET o_chair2.x_min = 1100, o_chair2.y_min = 320, o_chair2.x_max = 1230, o_chair2.y_max = 600

MERGE (img)-[:CONTAINS]->(o_person)
MERGE (img)-[:CONTAINS]->(o_dog)
MERGE (img)-[:CONTAINS]->(o_bike)
MERGE (img)-[:CONTAINS]->(o_chair1)
MERGE (img)-[:CONTAINS]->(o_chair2)

MERGE (o_person)-[:IS_A]->(person)
MERGE (o_dog)-[:IS_A]->(dog)
MERGE (o_bike)-[:IS_A]->(bicycle)
MERGE (o_chair1)-[:IS_A]->(chair)
MERGE (o_chair2)-[:IS_A]->(chair)

// Spatial relationships (directional ones are stored in both directions;
// NEAR is symmetric and stored once).
MERGE (o_person)-[:NEAR]->(o_dog)
MERGE (o_person)-[:LEFT_OF]->(o_bike)
MERGE (o_bike)-[:RIGHT_OF]->(o_person)
MERGE (o_dog)-[:BELOW]->(o_person)
MERGE (o_person)-[:ABOVE]->(o_dog)
MERGE (o_dog)-[:LEFT_OF]->(o_bike)
MERGE (o_bike)-[:RIGHT_OF]->(o_dog)
MERGE (o_bike)-[:NEAR]->(o_chair1)
MERGE (o_bike)-[:LEFT_OF]->(o_chair1)
MERGE (o_chair1)-[:RIGHT_OF]->(o_bike)
MERGE (o_chair1)-[:LEFT_OF]->(o_chair2)
MERGE (o_chair2)-[:RIGHT_OF]->(o_chair1)
MERGE (o_chair1)-[:NEAR]->(o_chair2)
40 changes: 40 additions & 0 deletions backend/src/visual_graph/graph/seed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Load the manually created sample scene graph into Neo4j."""

from importlib import resources

from neo4j import Driver

from visual_graph.graph.connection import create_driver, verify_connection
from visual_graph.graph.schema import apply_schema

SAMPLE_IMAGE_ID = "sample-street-1"


def sample_scene_cypher() -> str:
return (
resources.files("visual_graph.graph")
.joinpath("cypher/seed_sample_scene.cypher")
.read_text(encoding="utf-8")
)


def seed_sample_scene(driver: Driver, database: str | None = None) -> None:
"""Create the schema (if needed) and the sample scene. Safe to run repeatedly."""
apply_schema(driver, database)
with driver.session(database=database) as session:
session.run(sample_scene_cypher()).consume()


def main() -> None:
driver = create_driver()
try:
verify_connection(driver)
seed_sample_scene(driver)
finally:
driver.close()
print(f"Sample scene '{SAMPLE_IMAGE_ID}' loaded. Inspect it in Neo4j Browser with:")
print(f'MATCH p=(:Image {{id: "{SAMPLE_IMAGE_ID}"}})-[*1..2]->() RETURN p')


if __name__ == "__main__":
main()
95 changes: 95 additions & 0 deletions backend/tests/integration/test_seed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
from collections.abc import Iterator

import pytest
from neo4j import Driver

from visual_graph.graph.connection import create_driver
from visual_graph.graph.seed import SAMPLE_IMAGE_ID, seed_sample_scene

pytestmark = pytest.mark.integration


@pytest.fixture
def driver() -> Iterator[Driver]:
drv = create_driver()
with drv.session() as session:
session.run("MATCH (n) DETACH DELETE n").consume()
seed_sample_scene(drv)
yield drv
with drv.session() as session:
session.run("MATCH (n) DETACH DELETE n").consume()
drv.close()


def scalar(driver: Driver, query: str) -> int:
with driver.session() as session:
record = session.run(query, image_id=SAMPLE_IMAGE_ID).single()
assert record is not None
return int(record[0])


def test_graph_shape(driver: Driver) -> None:
assert scalar(driver, "MATCH (:Image) RETURN count(*)") == 1
assert scalar(driver, "MATCH (:ObjectInstance) RETURN count(*)") == 5
assert scalar(driver, "MATCH (:Class) RETURN count(*)") == 4


def test_seed_is_idempotent(driver: Driver) -> None:
before = scalar(driver, "MATCH (n) RETURN count(n)"), scalar(
driver, "MATCH ()-[r]->() RETURN count(r)"
)
seed_sample_scene(driver)
after = scalar(driver, "MATCH (n) RETURN count(n)"), scalar(
driver, "MATCH ()-[r]->() RETURN count(r)"
)
assert before == after


def test_every_instance_has_one_class_and_image(driver: Driver) -> None:
assert (
scalar(
driver,
"MATCH (o:ObjectInstance) "
"WHERE size([(o)-[:IS_A]->(:Class) | 1]) <> 1 "
"OR size([(:Image)-[:CONTAINS]->(o) | 1]) <> 1 RETURN count(o)",
)
== 0
)


def test_multiple_instances_of_same_class(driver: Driver) -> None:
assert (
scalar(
driver,
"MATCH (:Image {id: $image_id})-[:CONTAINS]->(o)-[:IS_A]->(:Class {name: 'chair'}) "
"RETURN count(o)",
)
== 2
)


def test_spatial_relationships_are_queryable(driver: Driver) -> None:
assert (
scalar(
driver,
"MATCH (:Class {name:'person'})<-[:IS_A]-(p)-[:NEAR]-(d)"
"-[:IS_A]->(:Class {name:'dog'}) RETURN count(*)",
)
== 1
)
assert (
scalar(
driver,
"MATCH (:Class {name:'person'})<-[:IS_A]-(p)-[:LEFT_OF]->(b)-[:IS_A]->"
"(:Class {name:'bicycle'}) RETURN count(*)",
)
== 1
)
assert (
scalar(
driver,
"MATCH (:Class {name:'dog'})<-[:IS_A]-(d)-[:BELOW]->(p)-[:IS_A]->"
"(:Class {name:'person'}) RETURN count(*)",
)
== 1
)
Loading